{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "infinite-tunnel",
  "type": "registry:block",
  "title": "Infinite Tunnel",
  "description": "An endless tunnel of glowing rings that the camera flies through, lit by a cool key and a warm rim. It bends toward the cursor on a spring, and pressing and holding ramps it into warp.",
  "docs": "Infinite Tunnel was added to public/vividsites/infinite-tunnel.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/infinite-tunnel",
  "categories": [
    "3d",
    "3d-objects"
  ],
  "files": [
    {
      "path": "components/vividsites/infinite-tunnel.html",
      "type": "registry:file",
      "target": "public/vividsites/infinite-tunnel.html",
      "content": "<!--\n  vividsites · infinite-tunnel\n  An endless tunnel of light rings that the camera flies through. Rings recycle\n  forever, so it never ends. The tunnel bends toward the cursor on a spring and\n  banks into the turn; press and hold (or hold Space) to warp: the speed ramps,\n  the lens widens and the wall streaks stretch. Each ring is lit by two lights,\n  a cool key from one side and a warm rim from the other, with an additive glow.\n\n  Customise (attributes, read live):\n    data-shape       circle | square | hex   (morphs between them)\n    data-palette     \"#5ff2ff,#ffb35c\"        key colour, rim colour\n    data-speed       cruise speed, 0–3 (default 1)\n  CSS:\n    --vs-infinite-tunnel-bg       ground colour (default #031214)\n    --vs-infinite-tunnel-height   component height (default 100svh)\n  JS (on the element): el.vsInfiniteTunnel\n    .set({ shape, palette: ['#hex','#hex'], speed })\n    .warp(true|false)            hold / release warp from code\n    .destroy()\n  Needs: three@0.170.0 (importmap below). No WebGL → a static CSS ring fallback.\n-->\n<div class=\"vs-infinite-tunnel\" data-shape=\"circle\" data-palette=\"#5ff2ff,#ffb35c\" data-speed=\"1\" tabindex=\"0\" role=\"img\" aria-label=\"A tunnel of light rings flying toward you. Move to steer, press and hold to warp.\">\n  <canvas class=\"vs-infinite-tunnel-canvas\"></canvas>\n</div>\n\n<style>\n  .vs-infinite-tunnel {\n    --vs-infinite-tunnel-bg: #031214;\n    --vs-infinite-tunnel-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-infinite-tunnel-height); min-height: 360px; overflow: hidden; isolation: isolate;\n    background: var(--vs-infinite-tunnel-bg); outline: none; cursor: crosshair; touch-action: pan-y;\n    -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none;\n  }\n  .vs-infinite-tunnel:focus-visible { box-shadow: inset 0 0 0 1.5px rgba(95,242,255,.55); }\n  .vs-infinite-tunnel-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity 1.2s cubic-bezier(.16,1,.3,1); }\n  .vs-infinite-tunnel[data-vs-state=\"ready\"] .vs-infinite-tunnel-canvas { opacity: 1; }\n  /* lens vignette + fine grain over the render */\n  .vs-infinite-tunnel::after { content: \"\"; position: absolute; inset: 0; pointer-events: none;\n    background: radial-gradient(130% 110% at 50% 50%, transparent 55%, rgba(0,4,5,.45) 100%); }\n  /* no WebGL: concentric rings drawn in CSS */\n  .vs-infinite-tunnel[data-vs-state=\"nogl\"] { background:\n      repeating-radial-gradient(circle at 50% 50%, transparent 0 22px, rgba(95,242,255,.35) 23px 24px, transparent 25px 46px, rgba(255,179,92,.3) 47px 48px),\n      radial-gradient(circle at 50% 50%, rgba(95,242,255,.25), transparent 40%), var(--vs-infinite-tunnel-bg); }\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 SEG = 240;             // divisible by 3, 4, 5, 6 and 8, so polygon corners land on a vertex\nconst RINGS = 110;\nconst SPACING = 0.78;\n\n// shared: the tunnel's centre-line in camera space, and each ring's shape\nconst PATH = /* glsl */`\nuniform float uTravel, uTime;\nuniform vec2 uBend;          // cursor steer (spring-smoothed)\nuniform vec4 uShape;         // sidesA, sidesB, mix, radius\nvec2 wander(float s){ return vec2(sin(s * 0.043) * 1.6 + sin(s * 0.017 + 1.3) * 2.2, cos(s * 0.031) * 1.1 + sin(s * 0.013 + 0.4) * 1.5); }\nvec2 wanderD(float s){ return vec2(cos(s * 0.043) * 0.0688 + cos(s * 0.017 + 1.3) * 0.0374, -sin(s * 0.031) * 0.0341 + cos(s * 0.013 + 0.4) * 0.0195); }\n// the centre of the tunnel at depth d ahead of the camera\nvec2 centre(float d){\n  vec2 w = wander(uTravel + d) - wander(uTravel) - wanderD(uTravel) * d;\n  return w + uBend * d * d * 0.034 / (1.0 + d * 0.07);\n}\nfloat polyR(float a, float n){\n  if (n < 2.5) return 1.0;\n  float rot = n > 3.5 && n < 4.5 ? 0.78539816 : (n > 5.5 ? 0.0 : 1.5707963);\n  float seg = 6.2831853 / n;\n  float m = mod(a + rot, seg) - seg * 0.5;\n  return cos(3.14159265 / n) / cos(m);\n}\nfloat shapeR(float a){ return mix(polyR(a, uShape.x), polyR(a, uShape.y), uShape.z) * uShape.w; }\nfloat hash(float n){ return fract(sin(n * 127.1) * 43758.5453); }\n`;\n\nconst RING_VERT = /* glsl */`\nattribute vec2 aUV;          // x: around the ring 0..1, y: across -1..1\nattribute float aIdx;\n${PATH}\nuniform float uSpeedK;\nvarying vec2 vUV; varying float vDepth; varying float vId; varying float vAng;\nvoid main(){\n  float base = floor(uTravel / ${SPACING.toFixed(2)});\n  float d = aIdx * ${SPACING.toFixed(2)} - (uTravel - base * ${SPACING.toFixed(2)});\n  float id = base + aIdx;\n  float a = aUV.x * 6.2831853;\n  float twist = id * 0.045 + uTime * 0.06;\n  float r = shapeR(a + twist);\n  // halo width in world units grows with depth so a far ring keeps a few pixels of glow\n  float hw = 0.07 + d * 0.0035;\n  vec2 dir = vec2(cos(a), sin(a));\n  vec2 p = centre(d) + dir * (r + aUV.y * hw);\n  vUV = aUV; vDepth = d; vId = id; vAng = a;\n  gl_Position = projectionMatrix * vec4(p, -d, 1.0);\n}`;\nconst RING_FRAG = /* glsl */`\nuniform vec3 uKey, uRim; uniform float uTime, uSpeedK, uKeyA;\nvarying vec2 vUV; varying float vDepth; varying float vId; varying float vAng;\nfloat hash(float n){ return fract(sin(n * 127.1) * 43758.5453); }\nvoid main(){\n  float v = vUV.y;\n  float core = exp(-v * v * 26.0);\n  float halo = exp(-v * v * 3.2) * 0.22;\n  // per-ring character: most are whole, some dashed, some dotted, a few dim\n  float h = hash(vId), h2 = hash(vId + 17.0);\n  float t = vUV.x;\n  float dash = 1.0;\n  if (h > 0.55 && h < 0.8) { float k = floor(6.0 + h2 * 18.0); float f = fract(t * k + h2); float fw = fwidth(t * k) * 1.5; dash = smoothstep(0.0, fw, f) * (1.0 - smoothstep(0.55 - fw, 0.55, f)); }\n  else if (h >= 0.8) { float k = floor(40.0 + h2 * 50.0); float f = abs(fract(t * k) - 0.5); float fw = fwidth(t * k) * 1.5; dash = 1.0 - smoothstep(0.12, 0.12 + fw, f); }\n  float bright = 0.55 + 0.45 * hash(vId + 3.0);\n  // a pulse of light that runs down the tunnel toward you every few seconds\n  float pulse = smoothstep(0.9, 1.0, fract(vId / 28.0 + uTime * 0.22));\n  bright += pulse * 1.6;\n  // two lights: cool key from one side, warm rim from the other\n  float l = cos(vAng - uKeyA);\n  vec3 col = mix(uRim, uKey, smoothstep(-0.35, 0.55, l));\n  vec3 c = col * (core * 1.25 + halo) * dash * bright;\n  c += vec3(1.0) * pow(core, 3.0) * 0.35 * dash * bright;   // white-hot filament\n  // fade in from the dark far end, and out as a ring passes the lens\n  float fade = exp(-vDepth * 0.034) * smoothstep(0.25, 2.2, vDepth);\n  gl_FragColor = vec4(c * fade * 0.85, 1.0);\n}`;\n\nconst STREAK_VERT = /* glsl */`\nattribute vec2 aQ;           // x: along 0..1, y: across -1..1\nattribute vec4 aS;           // angle, depth seed, radius jitter, brightness\n${PATH}\nuniform float uStreakLen, uSpeedK;\nvarying float vA; varying float vY; varying float vT; varying float vD;\nvoid main(){\n  float span = ${(RINGS * SPACING).toFixed(1)};\n  float d0 = mod(aS.y * span - uTravel * (1.0 + aS.w * 0.4), span);\n  float d = d0 + aQ.x * uStreakLen * (0.6 + aS.w);\n  float r = shapeR(aS.x + uTime * 0.06) * (0.62 + aS.z * 0.32);\n  vec2 dir = vec2(cos(aS.x), sin(aS.x));\n  vec2 side = vec2(-dir.y, dir.x);\n  float w = (0.008 + d * 0.0012) * (1.0 + uSpeedK * 0.8);\n  vec2 p = centre(d) + dir * r + side * aQ.y * w;\n  vA = aS.w; vY = aQ.y; vT = aQ.x; vD = d0;\n  gl_Position = projectionMatrix * vec4(p, -d, 1.0);\n}`;\nconst STREAK_FRAG = /* glsl */`\nuniform vec3 uKey, uRim; uniform float uSpeedK;\nvarying float vA; varying float vY; varying float vT; varying float vD;\nvoid main(){\n  float a = exp(-vY * vY * 4.0) * (1.0 - vT) * vA;\n  float fade = exp(-vD * 0.035) * smoothstep(0.2, 3.0, vD);\n  vec3 c = mix(uKey, uRim, step(0.5, fract(vA * 7.0))) * a * fade * (0.55 + uSpeedK * 2.0);\n  gl_FragColor = vec4(c, 1.0);\n}`;\n\nconst GLOW_VERT = /* glsl */`\n${PATH}\nvarying vec2 vP;\nvoid main(){ vP = position.xy; float d = 60.0; vec2 c = centre(d); gl_Position = projectionMatrix * vec4(c + position.xy * 70.0, -d, 1.0); }`;\nconst GLOW_FRAG = /* glsl */`\nuniform vec3 uKey, uRim; uniform float uSpeedK;\nvarying vec2 vP;\nvoid main(){ float r = length(vP); float q = r * 2.33; float g = exp(-q * q * 9.0) * 0.14 + exp(-q * q * 60.0) * 0.18; vec3 c = mix(uKey, vec3(1.0), 0.25) * g * (1.0 + uSpeedK * 0.8) + uKey * exp(-q * q * 1.2) * 0.035 + uRim * exp(-q * q * 3.0) * 0.02; gl_FragColor = vec4(c * (1.0 - smoothstep(0.6, 1.0, r)), 1.0); }`;\n\nconst SIDES = { circle: 0, square: 4, hex: 6 };\nconst css = (el, n, f) => getComputedStyle(el).getPropertyValue(n).trim() || f;\n\nfunction mount(el) {\n  const canvas = el.querySelector('.vs-infinite-tunnel-canvas');\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  let renderer;\n  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); } catch (e) { el.dataset.vsState = 'nogl'; return null; }\n  if (!renderer.getContext()) { el.dataset.vsState = 'nogl'; return null; }\n  const bg = new THREE.Color().setStyle(css(el, '--vs-infinite-tunnel-bg', '#031214'), THREE.LinearSRGBColorSpace);   // the output is written as-is, so keep the ground in display space\n  renderer.setClearColor(bg, 1);\n  renderer.outputColorSpace = THREE.LinearSRGBColorSpace;   // colours are authored in display space; the shaders add light directly\n\n  const pal = () => (el.dataset.palette || '#5ff2ff,#ffb35c').split(',').map((s) => new THREE.Color(s.trim()));\n  const [k0, r0] = pal();\n  const U = {\n    uTravel: { value: 0 }, uTime: { value: 0 }, uBend: { value: new THREE.Vector2() },\n    uShape: { value: new THREE.Vector4(SIDES[el.dataset.shape] ?? 0, SIDES[el.dataset.shape] ?? 0, 1, 1) },\n    uKey: { value: k0 }, uRim: { value: r0 }, uSpeedK: { value: 0 }, uKeyA: { value: 2.3 }, uStreakLen: { value: 0.6 },\n  };\n  const add = { transparent: true, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending };\n\n  // ---- rings: one strip, instanced RINGS times\n  const rp = [], ruv = [], ri = [];\n  for (let i = 0; i <= SEG; i++) for (const y of [-1, 1]) { rp.push(0, 0, 0); ruv.push(i / SEG, y); }\n  for (let i = 0; i < SEG; i++) { const a = i * 2; ri.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); }\n  const ringGeo = new THREE.InstancedBufferGeometry();\n  ringGeo.setAttribute('position', new THREE.Float32BufferAttribute(rp, 3));\n  ringGeo.setAttribute('aUV', new THREE.Float32BufferAttribute(ruv, 2)); ringGeo.setIndex(ri);\n  // draw far rings first so nothing depends on order, but it keeps overdraw predictable\n  ringGeo.setAttribute('aIdx', new THREE.InstancedBufferAttribute(new Float32Array([...Array(RINGS).keys()].reverse()), 1));\n  ringGeo.instanceCount = RINGS;\n  const ringMat = new THREE.ShaderMaterial({ uniforms: U, vertexShader: RING_VERT, fragmentShader: RING_FRAG, ...add });\n  const rings = new THREE.Mesh(ringGeo, ringMat); rings.frustumCulled = false;\n\n  // ---- streaks on the walls\n  const SN = matchMedia('(pointer: coarse)').matches ? 260 : 520;\n  const sGeo = new THREE.InstancedBufferGeometry();\n  sGeo.setAttribute('position', new THREE.Float32BufferAttribute([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3));\n  sGeo.setAttribute('aQ', new THREE.Float32BufferAttribute([0, -1, 0, 1, 1, -1, 1, 1], 2)); sGeo.setIndex([0, 1, 2, 1, 3, 2]);\n  const sa = new Float32Array(SN * 4); let seed = 7;\n  const R = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };\n  for (let i = 0; i < SN; i++) sa.set([R() * Math.PI * 2, R(), R(), 0.2 + R() * 0.8], i * 4);\n  sGeo.setAttribute('aS', new THREE.InstancedBufferAttribute(sa, 4)); sGeo.instanceCount = SN;\n  const streaks = new THREE.Mesh(sGeo, new THREE.ShaderMaterial({ uniforms: U, vertexShader: STREAK_VERT, fragmentShader: STREAK_FRAG, ...add }));\n  streaks.frustumCulled = false;\n\n  // ---- the glow at the far end\n  const glow = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms: U, vertexShader: GLOW_VERT, fragmentShader: GLOW_FRAG, ...add }));\n  glow.frustumCulled = false;\n\n  const scene = new THREE.Scene(); scene.add(glow, streaks, rings);\n  const camera = new THREE.PerspectiveCamera(68, 1, 0.05, 200);   // sits at the origin looking down -z; the tunnel is built in its space\n\n  const S = {\n    t: 0, last: 0, raf: 0, visible: true, travel: 0,\n    speed: 0, speedV: 0, cruise: parseFloat(el.dataset.speed || 1), warp: false,\n    bx: 0, by: 0, bvx: 0, bvy: 0, tx: 0, ty: 0, roll: 0, fov: 68,\n    shapeFrom: U.uShape.value.x, shapeTo: U.uShape.value.y, shapeMix: 1,\n    colFrom: [k0.clone(), r0.clone()], colTo: [k0.clone(), r0.clone()], colMix: 1,\n  };\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    // portrait screens: widen the lens so the rings still wrap the frame\n    S.baseFov = w / h < 0.8 ? 92 : 68;\n    U.uShape.value.w = w / h < 0.8 ? 1.25 : 1;\n  }\n\n  function set(o = {}) {\n    if (o.shape != null && o.shape in SIDES) {\n      el.dataset.shape = o.shape;\n      const cur = THREE.MathUtils.lerp(S.shapeFrom, S.shapeTo, S.shapeMix);\n      // morph from whatever shape is on screen now\n      S.shapeFrom = S.shapeMix < 0.5 ? S.shapeFrom : S.shapeTo; S.shapeTo = SIDES[o.shape]; S.shapeMix = reduced ? 1 : 0;\n      void cur;\n    }\n    if (o.palette) {\n      const p = Array.isArray(o.palette) ? o.palette : String(o.palette).split(',');\n      el.dataset.palette = p.join(',');\n      S.colFrom = [U.uKey.value.clone(), U.uRim.value.clone()]; S.colTo = p.map((c) => new THREE.Color(String(c).trim())); S.colMix = reduced ? 1 : 0;\n    }\n    if (o.speed != null) { S.cruise = Math.max(0, +o.speed); el.dataset.speed = String(S.cruise); }\n    kick();\n  }\n  const warp = (v) => { S.warp = !!v; el.toggleAttribute('data-warping', S.warp); kick(); };\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    // speed: a spring toward cruise or warp, so the ramp has weight and releases with a glide\n    const target = (reduced ? 0.25 : 1) * S.cruise * (S.warp ? 6.5 : 1) * 4.2;\n    const ks = S.warp ? 18 : 10, cs = 2 * Math.sqrt(ks) * 0.9;\n    S.speedV += (ks * (target - S.speed) - cs * S.speedV) * dt; S.speed += S.speedV * dt;\n    S.travel += S.speed * dt;\n    const sk = THREE.MathUtils.clamp((S.speed / (4.2 * Math.max(S.cruise, 0.3)) - 1) / 5.5, 0, 1);\n    // steer: an under-damped spring on the bend, so the tunnel swings after the cursor and settles\n    const kb = 14, cb = 5.2;\n    S.bvx += (kb * (S.tx - S.bx) - cb * S.bvx) * dt; S.bvy += (kb * (S.ty - S.by) - cb * S.bvy) * dt;\n    S.bx += S.bvx * dt; S.by += S.bvy * dt;\n    U.uBend.value.set(S.bx, S.by);\n    // bank into the turn\n    S.roll += (-S.bx * 0.16 - S.roll) * (1 - Math.exp(-dt * 4));\n    camera.rotation.z = S.roll;\n    const fovT = S.baseFov + sk * 22;\n    S.fov += (fovT - S.fov) * (1 - Math.exp(-dt * 5));\n    if (Math.abs(camera.fov - S.fov) > 0.01) { camera.fov = S.fov; camera.updateProjectionMatrix(); }\n    // shape and palette crossfades\n    if (S.shapeMix < 1) S.shapeMix = Math.min(1, S.shapeMix + dt / 0.9);\n    const e = S.shapeMix < 0.5 ? 4 * S.shapeMix ** 3 : 1 - (-2 * S.shapeMix + 2) ** 3 / 2;\n    U.uShape.value.x = S.shapeFrom; U.uShape.value.y = S.shapeTo; U.uShape.value.z = e;\n    if (S.colMix < 1) { S.colMix = Math.min(1, S.colMix + dt / 0.7); U.uKey.value.copy(S.colFrom[0]).lerp(S.colTo[0], S.colMix); U.uRim.value.copy(S.colFrom[1]).lerp(S.colTo[1], S.colMix); }\n    U.uTravel.value = S.travel; U.uTime.value = S.t; U.uSpeedK.value = sk; U.uStreakLen.value = 0.4 + sk * 9 + S.speed * 0.05;\n    renderer.render(scene, camera);\n    if (el.dataset.vsState !== 'ready') el.dataset.vsState = 'ready';\n    const moving = !reduced || S.warp || Math.abs(S.speedV) > 0.01 || Math.abs(S.bvx) + Math.abs(S.bvy) > 0.001 || S.shapeMix < 1 || S.colMix < 1;\n    if (S.visible && !document.hidden && moving) S.raf = requestAnimationFrame(frame);\n  }\n  const kick = () => { if (!S.raf && S.visible) { S.last = performance.now(); S.raf = requestAnimationFrame(frame); } };\n\n  const aim = (e) => { const r = el.getBoundingClientRect(); S.tx = ((e.clientX - r.left) / r.width) * 2 - 1; S.ty = -((e.clientY - r.top) / r.height) * 2 + 1; kick(); };\n  const onMove = (e) => aim(e);\n  const onDown = (e) => { aim(e); if (e.button > 0) return; warp(true); try { el.setPointerCapture(e.pointerId); } catch (_) {} };\n  const onUp = () => warp(false);\n  const onLeave = (e) => { if (e.pointerType === 'mouse' && !S.warp) { S.tx = 0; S.ty = 0; kick(); } };\n  const onKey = (e) => { if (e.code === 'Space') { e.preventDefault(); if (!e.repeat) warp(e.type === 'keydown'); } };\n  const onCtx = (e) => e.preventDefault();\n  el.addEventListener('pointermove', onMove); el.addEventListener('pointerdown', onDown); el.addEventListener('pointerup', onUp);\n  el.addEventListener('pointercancel', onUp); el.addEventListener('pointerleave', onLeave); el.addEventListener('keydown', onKey); el.addEventListener('keyup', onKey);\n  el.addEventListener('contextmenu', onCtx);\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: '80px' }); 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    el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerdown', onDown); el.removeEventListener('pointerup', onUp);\n    el.removeEventListener('pointercancel', onUp); el.removeEventListener('pointerleave', onLeave); el.removeEventListener('keydown', onKey); el.removeEventListener('keyup', onKey);\n    el.removeEventListener('contextmenu', onCtx);\n    canvas.removeEventListener('webglcontextlost', onLost); canvas.removeEventListener('webglcontextrestored', onRestored);\n    ringGeo.dispose(); sGeo.dispose(); ringMat.dispose(); renderer.dispose();\n  }\n  resize(); camera.fov = S.fov = S.baseFov; camera.updateProjectionMatrix();\n  S.travel = 40; if (reduced) S.speed = 0;\n  kick();\n  return { set, warp, destroy, get speed() { return S.speed; } };\n}\n\ndocument.querySelectorAll('.vs-infinite-tunnel:not([data-vs-mounted])').forEach((el) => {\n  el.dataset.vsMounted = '';\n  const go = () => { const api = mount(el); if (api) el.vsInfiniteTunnel = api; };\n  el.__vsRemount = go; go();\n});\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D objects",
    "tags": [
      "tunnel",
      "rings",
      "warp",
      "glow",
      "hero",
      "background",
      "pointer",
      "shader"
    ],
    "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": "#5ff2ff",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/infinite-tunnel/index.html",
    "thumbnail": "https://vividsites.app/api/component/infinite-tunnel/thumb.jpg",
    "video": "https://vividsites.app/api/component/infinite-tunnel/thumb.mp4",
    "page": "https://vividsites.app/c/infinite-tunnel"
  }
}