{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "glass-orb",
  "type": "registry:block",
  "title": "Glass Orb",
  "description": "A solid glass sphere in a light studio sweep that refracts, inverts and splits a printed stripe, grid or dot graphic on the wall behind it, with a soft shadow and a spectral caustic on the floor. It follows the pointer on a spring; the glass switches between crown, flint, frosted and smoke.",
  "docs": "Glass Orb was added to public/vividsites/glass-orb.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/glass-orb",
  "categories": [
    "3d",
    "3d-objects"
  ],
  "files": [
    {
      "path": "components/vividsites/glass-orb.html",
      "type": "registry:file",
      "target": "public/vividsites/glass-orb.html",
      "content": "<!--\n  vividsites · glass-orb\n  A solid glass sphere floating in a studio sweep. The wall behind it carries a\n  bold printed graphic (stripes, grid or dots), which the orb refracts, inverts\n  and splits into spectral fringes (real transmission + dispersion). It throws a\n  soft shadow with a focused caustic on the floor, and follows the pointer on a\n  spring (touch: drag it).\n\n  Customise (attributes, read at mount; all live through the JS API too):\n    data-glass       crown | flint | frost | smoke            (default flint)\n    data-backdrop    stripes | grid | dots                    (default stripes)\n    data-stage       light | dark                             (default light)\n    data-dispersion  spectral split multiplier, 0 – 2         (default 1)\n  CSS variables:\n    --vs-glass-orb-bg      the studio sweep                   (light #e9e5df, dark #1c1b19)\n    --vs-glass-orb-ink     the printed graphic                (light #1b1a18, dark #e9e4dc)\n    --vs-glass-orb-accent  the caustic's warm core            (#ffb86b)\n    --vs-glass-orb-height  component height                   (100svh)\n    --vs-glass-orb-speed   ambient drift multiplier, 0 = still (1)\n  JS:\n    el.vsGlassOrb.set({ glass: 'frost', backdrop: 'dots', dispersion: 1.5, stage: 'dark' })\n    el.vsGlassOrb.destroy()\n  Needs: three@0.170.0 (importmap below). Remove the importmap if your page already maps \"three\".\n-->\n<div class=\"vs-glass-orb\" data-glass=\"flint\" data-backdrop=\"stripes\" data-stage=\"light\" data-dispersion=\"1\" role=\"img\" aria-label=\"A glass orb refracting the printed graphic behind it\">\n  <canvas class=\"vs-glass-orb-canvas\"></canvas>\n  <div class=\"vs-glass-orb-fallback\" aria-hidden=\"true\"><i></i></div>\n</div>\n\n<style>\n  .vs-glass-orb {\n    --vs-glass-orb-bg: #e9e5df;\n    --vs-glass-orb-ink: #1b1a18;\n    --vs-glass-orb-accent: #ffb86b;\n    --vs-glass-orb-height: 100svh;\n    --vs-glass-orb-speed: 1;\n    position: relative; width: 100%; height: var(--vs-glass-orb-height); min-height: 420px;\n    overflow: hidden; isolation: isolate; background: var(--vs-glass-orb-bg);\n    -webkit-tap-highlight-color: transparent; touch-action: pan-y; transition: background-color .6s cubic-bezier(.16,1,.3,1);\n  }\n  .vs-glass-orb[data-stage=\"dark\"] { --vs-glass-orb-bg: #1c1b19; --vs-glass-orb-ink: #e9e4dc; }\n  .vs-glass-orb-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-glass-orb[data-vs-state=\"ready\"] .vs-glass-orb-canvas { opacity: 1; }\n  .vs-glass-orb::after { /* a whisper of paper grain over the render */\n    content: \"\"; position: absolute; inset: 0; pointer-events: none; z-index: 1;\n    background: url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><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 .5 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\");\n    mix-blend-mode: overlay; opacity: .28;\n  }\n  /* no WebGL: stripes and a drawn orb */\n  .vs-glass-orb-fallback { position: absolute; inset: 0; display: none; place-items: center;\n    background: repeating-linear-gradient(90deg, var(--vs-glass-orb-ink) 0 21px, transparent 21px 42px) center 0 / 100% 62% no-repeat, var(--vs-glass-orb-bg); }\n  .vs-glass-orb-fallback i { width: min(60vmin, 460px); aspect-ratio: 1; border-radius: 50%; -webkit-backdrop-filter: blur(3px) invert(1); backdrop-filter: blur(3px) invert(1);\n    background: radial-gradient(circle at 34% 28%, rgba(255,255,255,.9) 0 3%, rgba(255,255,255,.15) 9%, transparent 30%);\n    box-shadow: inset 0 0 40px rgba(255,255,255,.25), 0 50px 60px -30px rgba(0,0,0,.35); }\n  .vs-glass-orb[data-vs-state=\"nogl\"] .vs-glass-orb-fallback { display: grid; }\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 PRESETS = {\n  crown: { ior: 1.52, dispersion: 1.4, rough: 0.0, thick: 1.4, tint: '#ffffff', dist: 14 },\n  flint: { ior: 1.66, dispersion: 3.4, rough: 0.0, thick: 4.0, tint: '#fffaf2', dist: 9 },\n  frost: { ior: 1.5, dispersion: 2.0, rough: 0.34, thick: 1.6, tint: '#f6f8ff', dist: 10 },\n  smoke: { ior: 1.55, dispersion: 1.6, rough: 0.02, thick: 2.0, tint: '#b08a70', dist: 3.2 },\n};\nconst BACKDROPS = { stripes: 0, grid: 1, dots: 2 };\n\nclass Spring { // damped spring, substepped so it's frame-rate independent\n  constructor(x, k = 120, c = 18) { this.x = x; this.v = 0; this.t = x; this.k = k; this.c = c; }\n  step(dt) { const n = Math.ceil(dt / (1 / 240)); const h = dt / n; for (let i = 0; i < n; i++) { this.v += (-this.k * (this.x - this.t) - this.c * this.v) * h; this.x += this.v * h; } return this.x; }\n}\n\nconst WALL_FRAG = /* glsl */`\n  uniform vec3 uBg, uInk, uAccent, uOrb, uPat; uniform float uRadius, uFloorY, uWallZ, uRough, uDark;\n  varying vec3 vW;\n  float pw(float x, float e){ return pow(max(x, 0.0), e); }\n  float cover(float d){ float aa = max(fwidth(d), 1e-4) * 0.75; return clamp(0.5 - d / aa, 0.0, 1.0); }\n  void main(){\n    vec3 c = uBg;\n    float wall = smoothstep(uWallZ + 0.8, uWallZ + 0.05, vW.z);\n    float onFloor = 1.0 - wall;\n    // the sweep's light: a broad softbox pool, high and a little left; the floor falls off toward the lens\n    vec2 q = vW.xy - vec2(-0.8, 1.6);\n    float pool = exp(-dot(q * vec2(0.1, 0.15), q * vec2(0.1, 0.15)));\n    c *= mix(mix(0.8, 0.55, uDark), mix(1.06, 1.35, uDark), pool);\n    c *= mix(1.0, mix(0.86, 0.6, uDark), smoothstep(uWallZ + 1.0, 9.0, vW.z) * onFloor);\n    // the printed graphic: stripes, grid, dots; signed distances, anti-aliased with fwidth\n    // each pattern is phased so the print's bottom edge (floor + .74) lands cleanly: on a grid line, between rows of dots\n    float yE = uFloorY + 0.74;\n    float P1 = 0.44; float ds = (abs(fract(vW.x / P1) - 0.5) - 0.25) * P1;\n    float P2 = 0.62; vec2 g = abs(fract((vW.xy - vec2(0.31, yE + 0.028)) / P2 + 0.5) - 0.5) * P2; float dg = min(g.x, g.y) - 0.028;\n    float P3 = 0.4; vec2 o = (fract((vW.xy - vec2(0.2, yE + 0.22)) / P3 + 0.5) - 0.5) * P3; float dd = length(o) - 0.118;\n    float pat = uPat.x * cover(-ds) + uPat.y * cover(dg) + uPat.z * cover(dd);\n    // the print hangs on the flat wall only, with a clean bottom edge above the curve\n    float edge = step(vW.z, uWallZ + 0.001) * cover(yE - vW.y);\n    c = mix(c, uInk * mix(0.9, 1.0, pool), pat * edge);\n    // floor: the orb's shadow, thrown forward and right by a key behind-left, and the caustic inside it\n    float h = max(uOrb.y - uFloorY, 0.05);\n    vec2 sd = vec2(0.2, 0.34) * (0.6 + 0.4 * h);\n    vec2 d = vW.xz - (uOrb.xz + sd);\n    float sr = uRadius * (0.9 + 0.3 * h);\n    float sh = exp(-dot(d * vec2(1.0, 0.8), d * vec2(1.0, 0.8)) / (sr * sr) * 1.5);\n    float core = exp(-dot(vW.xz - uOrb.xz, vW.xz - uOrb.xz) / (uRadius * uRadius * 0.35)) * smoothstep(0.9, 0.2, h); // contact darkening\n    c *= 1.0 - (mix(0.42, 0.7, uDark) * sh + 0.2 * core) * onFloor;\n    vec2 dc = vW.xz - (uOrb.xz + sd * 1.25);\n    float cr = uRadius * (0.2 + 0.08 * h) * (1.0 + uRough * 2.5);\n    float cl = length(dc * vec2(1.25, 0.95));\n    vec3 caus = vec3(exp(-pw(cl / (cr * 1.08), 2.0) * 2.2), exp(-pw(cl / cr, 2.0) * 2.2), exp(-pw(cl / (cr * 0.9), 2.0) * 2.2));\n    caus += vec3(1.0, 0.35, 0.05) * exp(-pw(abs(cl - cr * 1.25) / (cr * 0.28), 2.0)) * 0.22;\n    caus += vec3(0.1, 0.35, 1.0) * exp(-pw(abs(cl - cr * 1.55) / (cr * 0.32), 2.0)) * 0.18;\n    c += caus * mix(vec3(1.0), uAccent, 0.4) * onFloor * mix(0.95, 0.75, uDark) / (1.0 + uRough * 3.0);\n    gl_FragColor = vec4(c, 1.0);\n    #include <colorspace_fragment>\n  }`;\n\nfunction css(el, name, fallback) { const v = getComputedStyle(el).getPropertyValue(name).trim(); return v || fallback; }\n\nfunction mount(el) {\n  const canvas = el.querySelector('.vs-glass-orb-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' }); }\n  catch (e) { el.dataset.vsState = 'nogl'; return null; }\n  if (!renderer.getContext()) { el.dataset.vsState = 'nogl'; return null; }\n  // the stage is drawn untonemapped so it matches its CSS colour exactly; the glass gets Neutral, which keeps whites white\n  renderer.toneMapping = THREE.NeutralToneMapping; renderer.toneMappingExposure = 1;\n  if ('transmissionResolutionScale' in renderer) renderer.transmissionResolutionScale = 1;\n\n  const col = (v, f) => new THREE.Color(css(el, v, f));\n  const bg = col('--vs-glass-orb-bg', '#e9e5df'), ink = col('--vs-glass-orb-ink', '#1b1a18'), accent = col('--vs-glass-orb-accent', '#ffb86b');\n  const bgTo = bg.clone(), inkTo = ink.clone();\n  const speed = parseFloat(css(el, '--vs-glass-orb-speed', '1')) * (reduced ? 0 : 1);\n\n  const scene = new THREE.Scene();\n  const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 100);\n\n  // ---- the studio as an environment: softboxes in a room, baked to PMREM (one bake per stage tone)\n  const pmrem = new THREE.PMREMGenerator(renderer);\n  let envRT = null;\n  function bakeEnv(dark) {\n    const env = new THREE.Scene();\n    const eb = (c, i) => new THREE.MeshBasicMaterial({ color: new THREE.Color(c).multiplyScalar(i), side: THREE.DoubleSide });\n    const room = new THREE.Mesh(new THREE.BoxGeometry(24, 14, 24), eb(dark ? '#1c1a17' : '#cfcac2', dark ? 0.5 : 0.75)); room.material.side = THREE.BackSide; env.add(room);\n    const box = (w, h, x, y, z, c, i) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), eb(c, i)); m.position.set(x, y, z); m.lookAt(0, 0, 0); env.add(m); };\n    box(2.2, 9, -7, 1, 4, '#fff6ea', dark ? 3 : 3.4);   // tall strip, key, left-front\n    box(1.4, 9, 7.5, 0.5, 2, '#eef3ff', 2);             // strip, right\n    box(10, 3, 0, 6.8, 0, '#ffffff', 1.4);              // overhead\n    box(2, 9, -5, 1, -8, '#ffffff', 2.4);               // the key behind-left: a bright lip on the silhouette\n    box(8, 4, 0, 1, -11, dark ? '#302a24' : '#e6e1da', 1.0); // the sweep, seen behind\n    const rt = pmrem.fromScene(env, 0.02);\n    env.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); });\n    if (envRT) envRT.dispose(); envRT = rt; scene.environment = rt.texture;\n  }\n\n  // ---- the sweep: floor curving up into the wall\n  const FLOOR_Y = -1.35, WALL_Z = -3.4, R = 0.7;\n  const sweep = (() => {\n    const prof = [];\n    for (let i = 0; i <= 12; i++) prof.push([60 - i / 12 * (60 - (WALL_Z + R)), FLOOR_Y]);\n    for (let i = 1; i <= 20; i++) { const a = i / 20 * Math.PI / 2; prof.push([WALL_Z + R - Math.sin(a) * R, FLOOR_Y + R - Math.cos(a) * R]); }\n    for (let i = 1; i <= 10; i++) prof.push([WALL_Z, FLOOR_Y + R + i / 10 * 12]);\n    const g = new THREE.BufferGeometry(); const pos = []; const idx = []; const NX = 2;\n    prof.forEach(([z, y]) => { for (let j = 0; j <= NX; j++) pos.push(-60 + 120 * j / NX, y, z); });\n    for (let i = 0; i < prof.length - 1; i++) for (let j = 0; j < NX; j++) { const a = i * (NX + 1) + j, b = a + NX + 1; idx.push(a, b, a + 1, b, b + 1, a + 1); }\n    g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setIndex(idx); return g;\n  })();\n  const U = {\n    uBg: { value: bg }, uInk: { value: ink }, uAccent: { value: accent }, uOrb: { value: new THREE.Vector3() }, uPat: { value: new THREE.Vector3(1, 0, 0) },\n    uRadius: { value: 1 }, uFloorY: { value: FLOOR_Y }, uWallZ: { value: WALL_Z }, uRough: { value: 0 }, uDark: { value: 0 },\n  };\n  const wallMat = new THREE.ShaderMaterial({ uniforms: U, fragmentShader: WALL_FRAG, side: THREE.DoubleSide,\n    vertexShader: 'varying vec3 vW; void main(){ vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }' });\n  scene.add(new THREE.Mesh(sweep, wallMat));\n\n  // ---- the orb\n  const P0 = PRESETS[el.dataset.glass] || PRESETS.flint;\n  const glass = new THREE.MeshPhysicalMaterial({ color: 0xffffff, metalness: 0, roughness: P0.rough, transmission: 1, thickness: P0.thick, ior: P0.ior, dispersion: P0.dispersion,\n    attenuationColor: new THREE.Color(P0.tint), attenuationDistance: P0.dist, specularIntensity: 1, envMapIntensity: 1.2, clearcoat: 0.6, clearcoatRoughness: 0.04 });\n  // three's dispersion takes one sample per channel, so a white edge splits into three hard primaries.\n  // Take 8 samples across the spread instead and weight each into R/G/B: a continuous spectrum.\n  glass.onBeforeCompile = (sh) => {\n    const chunk = THREE.ShaderChunk.transmission_pars_fragment;\n    const a = chunk.indexOf('#ifdef USE_DISPERSION'), b = chunk.indexOf('#else', a);\n    if (a < 0 || b < 0) return;\n    sh.fragmentShader = sh.fragmentShader.replace('#include <transmission_pars_fragment>', chunk.slice(0, a) + `#ifdef USE_DISPERSION\n      float halfSpread = ( ior - 1.0 ) * 0.025 * dispersion;\n      vec3 wSum = vec3( 0.0 ); transmittedLight = vec4( 0.0 );\n      for ( int i = 0; i < 8; i ++ ) {\n        float t = ( float( i ) + 0.5 ) / 8.0;\n        float sIor = ior + halfSpread * ( t * 2.0 - 1.0 );\n        vec3 ray = getVolumeTransmissionRay( n, v, thickness, sIor, modelMatrix );\n        vec4 ndc = projMatrix * viewMatrix * vec4( position + ray, 1.0 );\n        vec4 s = getTransmissionSample( ( ndc.xy / ndc.w + 1.0 ) * 0.5, roughness, sIor );\n        vec3 w = clamp( 1.0 - abs( vec3( t ) - vec3( 0.14, 0.5, 0.86 ) ) * 2.3, 0.0, 1.0 );\n        transmittedLight.rgb += s.rgb * w; transmittedLight.a += s.a; wSum += w;\n      }\n      transmittedLight.rgb /= max( wSum, vec3( 1e-4 ) ); transmittedLight.a /= 8.0;\n      transmittance = diffuseColor.rgb * volumeAttenuation( length( getVolumeTransmissionRay( n, v, thickness, ior, modelMatrix ) ), attenuationColor, attenuationDistance );\n    ` + chunk.slice(b));\n  };\n  const orb = new THREE.Mesh(new THREE.SphereGeometry(1, 128, 96), glass);\n  scene.add(orb);\n\n  // ---- state\n  const state = { visible: true, raf: 0, last: 0, t: 0, px: 0, py: 0, hover: false, portrait: false, disp: 1 };\n  const sp = { x: new Spring(0, 90, 16), y: new Spring(0, 90, 16), s: new Spring(1, 260, 16) };\n  const mat = { ior: new Spring(P0.ior, 60, 15), disp: new Spring(P0.dispersion, 60, 15), rough: new Spring(P0.rough, 60, 15), thick: new Spring(P0.thick, 60, 15), dist: new Spring(P0.dist, 60, 15) };\n  const pat = { x: new Spring(1, 70, 16), y: new Spring(0, 70, 16), z: new Spring(0, 70, 16), dark: new Spring(0, 60, 15) };\n  const tint = new THREE.Color(P0.tint), tintTo = new THREE.Color(P0.tint);\n  let bounds = { x: 1.6, y: 0.5 };\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; state.portrait = w / h < 0.8;\n    // the orb's diameter ≈ 60% of the height on a wide screen, ≈ 80% of the width on a tall one\n    const vh = Math.max(2 / 0.6, 2 / 0.8 / camera.aspect);\n    const dist = vh / 2 / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));\n    const ly = state.portrait ? -0.2 : -0.12; camera.position.set(0, ly + 0.28, dist); camera.lookAt(0, ly, 0); /* near-level: the print's verticals stay vertical */ camera.updateProjectionMatrix();\n    const halfW = vh / 2 * camera.aspect; bounds = { x: Math.max(0.12, halfW - 1.15), y: state.portrait ? 0.3 : 0.4 };\n  }\n\n  function set(o = {}) {\n    if (typeof o === 'string') o = { glass: o };\n    if (o.glass && PRESETS[o.glass]) {\n      const p = PRESETS[o.glass]; el.dataset.glass = o.glass;\n      mat.ior.t = p.ior; mat.disp.t = p.dispersion; mat.rough.t = p.rough; mat.thick.t = p.thick; mat.dist.t = p.dist; tintTo.set(p.tint);\n      sp.s.v -= 1.4; // a small breath when the glass changes\n    }\n    if (o.backdrop && o.backdrop in BACKDROPS) { el.dataset.backdrop = o.backdrop; const k = BACKDROPS[o.backdrop]; pat.x.t = k === 0 ? 1 : 0; pat.y.t = k === 1 ? 1 : 0; pat.z.t = k === 2 ? 1 : 0; }\n    if (o.dispersion != null) { state.disp = Math.max(0, +o.dispersion); el.dataset.dispersion = String(state.disp); }\n    if (o.stage === 'light' || o.stage === 'dark') {\n      el.dataset.stage = o.stage; pat.dark.t = o.stage === 'dark' ? 1 : 0;\n      bgTo.set(css(el, '--vs-glass-orb-bg', o.stage === 'dark' ? '#1c1b19' : '#e9e5df')); inkTo.set(css(el, '--vs-glass-orb-ink', o.stage === 'dark' ? '#e9e4dc' : '#1b1a18'));\n      bakeEnv(o.stage === 'dark');\n    }\n    if (reduced) { for (const k in mat) mat[k].x = mat[k].t; for (const k in pat) pat[k].x = pat[k].t; }\n    kick();\n  }\n\n  function frame(now) {\n    state.raf = 0;\n    const dt = Math.min(0.05, (now - (state.last || now)) / 1000); state.last = now; state.t += dt * speed;\n    const t = state.t;\n    // pointer → target, with a slow idle drift when nobody is steering\n    const idleX = Math.sin(t * 0.35) * 0.3 * Math.min(1, bounds.x), idleY = Math.sin(t * 0.61) * 0.1;\n    sp.x.t = state.hover ? state.px * bounds.x : idleX; sp.y.t = state.hover ? state.py * bounds.y : idleY;\n    const x = reduced ? (sp.x.x = sp.x.t) : sp.x.step(dt), y = reduced ? (sp.y.x = sp.y.t) : sp.y.step(dt);\n    const s = reduced ? 1 : sp.s.step(dt);\n    const bob = Math.sin(t * 1.1) * 0.05;\n    orb.position.set(x, y + bob, 0); orb.scale.setScalar(s);\n    orb.rotation.z = -x * 0.6; orb.rotation.x = y * 0.4 + t * 0.1; // roll the ball the way it travels\n    for (const k in mat) mat[k].step(dt);\n    for (const k in pat) pat[k].step(dt);\n    glass.ior = mat.ior.x;\n    glass.dispersion = Math.max(0, mat.disp.x) * state.disp * (state.portrait ? 0.6 : 1); // a bigger orb on a phone splits more\n    glass.roughness = THREE.MathUtils.clamp(mat.rough.x, 0, 1);\n    glass.thickness = Math.max(0.1, mat.thick.x); glass.attenuationDistance = Math.max(0.2, mat.dist.x);\n    const k = 1 - Math.exp(-dt * 4);\n    tint.lerp(tintTo, k); glass.attenuationColor.copy(tint); bg.lerp(bgTo, k); ink.lerp(inkTo, k);\n    scene.environmentRotation.y = Math.sin(t * 0.12) * 0.5; // the softboxes sweep the highlight slowly\n    U.uOrb.value.copy(orb.position); U.uRadius.value = s; U.uRough.value = glass.roughness;\n    U.uPat.value.set(Math.max(0, pat.x.x), Math.max(0, pat.y.x), Math.max(0, pat.z.x)); U.uDark.value = THREE.MathUtils.clamp(pat.dark.x, 0, 1);\n    renderer.render(scene, camera);\n    if (el.dataset.vsState !== 'ready') el.dataset.vsState = 'ready';\n    const settling = Math.abs(sp.x.v) + Math.abs(sp.y.v) + Math.abs(sp.s.v) + Math.abs(mat.disp.v) + Math.abs(mat.ior.v) + Math.abs(pat.x.v) + Math.abs(pat.y.v) + Math.abs(pat.z.v) + Math.abs(pat.dark.v) > 1e-3\n      || Math.abs(bg.r - bgTo.r) > 1e-3 || Math.abs(tint.r - tintTo.r) + Math.abs(tint.b - tintTo.b) > 1e-3;\n    if (state.visible && !document.hidden && (speed > 0 || settling || state.hover)) state.raf = requestAnimationFrame(frame);\n  }\n  function kick() { if (!state.raf && state.visible) { state.last = performance.now(); state.raf = requestAnimationFrame(frame); } }\n\n  // ---- input: hover steers; touch drags\n  const onMove = (e) => {\n    const r = el.getBoundingClientRect();\n    state.px = THREE.MathUtils.clamp(((e.clientX - r.left) / r.width) * 2 - 1, -1, 1);\n    state.py = THREE.MathUtils.clamp(-(((e.clientY - r.top) / r.height) * 2 - 1), -1, 1);\n    state.hover = true; kick();\n  };\n  const onLeave = () => { state.hover = false; kick(); };\n  const onDown = (e) => { sp.s.t = 0.95; onMove(e); };\n  const onUp = (e) => { sp.s.t = 1; if (e.pointerType !== 'mouse') state.hover = false; kick(); };\n  el.addEventListener('pointermove', onMove); el.addEventListener('pointerleave', onLeave);\n  el.addEventListener('pointerdown', onDown); addEventListener('pointerup', onUp);\n\n  const ro = new ResizeObserver(() => { resize(); kick(); }); ro.observe(el);\n  const io = new IntersectionObserver(([en]) => { state.visible = en.isIntersecting; if (state.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(state.raf); state.raf = 0; state.visible = false; };\n  const onRestored = () => { destroy(); el.__vsGlassOrbRemount(); };\n  canvas.addEventListener('webglcontextlost', onLost); canvas.addEventListener('webglcontextrestored', onRestored);\n\n  function destroy() {\n    cancelAnimationFrame(state.raf); ro.disconnect(); io.disconnect();\n    document.removeEventListener('visibilitychange', onVis); removeEventListener('pointerup', onUp);\n    el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); el.removeEventListener('pointerdown', onDown);\n    canvas.removeEventListener('webglcontextlost', onLost); canvas.removeEventListener('webglcontextrestored', onRestored);\n    orb.geometry.dispose(); glass.dispose(); sweep.dispose(); wallMat.dispose(); if (envRT) envRT.dispose(); pmrem.dispose(); renderer.dispose();\n  }\n\n  const stage0 = el.dataset.stage === 'dark' ? 'dark' : 'light';\n  bakeEnv(stage0 === 'dark');\n  set({ glass: el.dataset.glass in PRESETS ? el.dataset.glass : 'flint', backdrop: el.dataset.backdrop in BACKDROPS ? el.dataset.backdrop : 'stripes', dispersion: parseFloat(el.dataset.dispersion || 1) });\n  for (const k in pat) pat[k].x = pat[k].t; // no fade on first paint\n  pat.dark.x = pat.dark.t = stage0 === 'dark' ? 1 : 0;\n  resize();\n  renderer.compile(scene, camera);\n  kick();\n  return { set, destroy, material: glass, orb };\n}\n\ndocument.querySelectorAll('.vs-glass-orb:not([data-vs-mounted])').forEach((el) => {\n  el.dataset.vsMounted = '';\n  const go = () => { const api = mount(el); if (api) el.vsGlassOrb = api; };\n  el.__vsGlassOrbRemount = go; go();\n});\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D objects",
    "tags": [
      "glass",
      "transmission",
      "dispersion",
      "refraction",
      "caustic",
      "studio",
      "pointer",
      "light"
    ],
    "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": "#1b1a18",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/glass-orb/index.html",
    "thumbnail": "https://vividsites.app/api/component/glass-orb/thumb.jpg",
    "video": "https://vividsites.app/api/component/glass-orb/thumb.mp4",
    "page": "https://vividsites.app/c/glass-orb"
  }
}