Glass refraction orb

Glass refraction orb

awwwards

Dark glass sphere with internal glow and a bright fresnel edge.

A 64-segment sphere shaded in two parts. Object-space position feeds a three-channel cosine palette whose channels are phase-offset, producing bands of colour that appear to sit inside the volume rather than on its skin — the cheap, convincing stand-in for refraction. Over that, a cubed fresnel term lays a hard lime edge exactly on the silhouette. Deep blue-black body, amber-to-lime interior, bright rim. Slower rotation than the fresnel sphere so the internal bands stay legible.

animatedwebgl3dthree-jseffectshaderglass

━━ Use this pattern

CDN deps
cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js

━━ Live preview

━━ HTML markup

<section class="gro-stage">
  <canvas class="gro-canvas"></canvas>
  <div class="gro-content">
    <h1 class="gro-title">Lit from the inside</h1>
    <p class="gro-sub">— Glass refraction orb · three.js</p>
  </div>
</section>

━━ CSS

.gro-stage {
  position: relative;
  min-height: 100vh;
  overflow: hidden;
  /* stageMode "dark": every Batch 02 effect is additive light, so the stage derives a
     dark ground FROM the spec (on-surface pulled 8% toward surface) rather than taking
     --color-surface directly. Both sides fall back to the same literal, so a token-less
     page renders exactly as before. */
  --gro-ink: color-mix(in oklab, var(--color-on-surface, #0a0a0a) 92%, var(--color-surface, #0a0a0a));
  /* Prefix-free alias, so a builder that doesn't know this stage's prefix (axes#fallbackFor,
     the shared lights() rig) can still read the derived ground. Element-scoped, so two
     Batch 02 stages on one page each see their own. */
  --tok-ink: var(--gro-ink);
  --gro-warm: color-mix(in oklab, var(--color-primary, #c8793a) 80%, var(--color-surface, #c8793a));
  --gro-bright: color-mix(in oklab, var(--color-secondary, var(--color-primary, #c8f564)) 80%, var(--color-surface, #c8f564));
  --gro-cool: color-mix(in oklab,
                color-mix(in oklab, var(--color-secondary, var(--color-primary, #456ec7)) 55%, var(--color-primary, #456ec7))
                80%, var(--color-surface, #456ec7));
  --gro-body: color-mix(in oklab, var(--color-on-surface, #080a0f) 92%, var(--color-surface, #080a0f));
  /* Also the graceful-degradation backdrop: the canvas paints over this when WebGL
     is available, so a blocked CDN or a failed context degrades to a static field
     rather than a black rectangle. */
  background:
    radial-gradient(40% 40% at 50% 48%, color-mix(in oklab, var(--gro-cool) 26%, transparent), transparent),
    radial-gradient(30% 30% at 50% 48%, color-mix(in oklab, var(--gro-bright) 18%, transparent), transparent),
    var(--gro-ink);
  display: flex;
  align-items: center;
  justify-content: center;
}
@supports (color: oklch(from red min(l, 1 - l) c h)) {
  .gro-stage {
    --gro-ds-ground: oklch(from color-mix(in oklab, var(--color-on-surface) 92%, var(--color-surface)) min(l, 1 - l) c h);
    --gro-ds-ink: oklch(from var(--color-surface) max(l, 1 - l) c h);
    --gro-ink: var(--gro-ds-ground, color-mix(in oklab, var(--color-on-surface, #0a0a0a) 92%, var(--color-surface, #0a0a0a)));
    --gro-warm: color-mix(in oklab, var(--color-primary, #c8793a) 80%, var(--gro-ds-ink, #c8793a));
    --gro-bright: color-mix(in oklab, var(--color-secondary, var(--color-primary, #c8f564)) 80%, var(--gro-ds-ink, #c8f564));
    --gro-cool: color-mix(in oklab,
                color-mix(in oklab, var(--color-secondary, var(--color-primary, #456ec7)) 55%, var(--color-primary, #456ec7))
                80%, var(--gro-ds-ink, #456ec7));
    --gro-body: var(--gro-ds-ground, color-mix(in oklab, var(--color-on-surface, #080a0f) 92%, var(--color-surface, #080a0f)));
  }
}
.gro-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: 0;
}
.gro-content {
  position: relative;
  z-index: 1;
  text-align: center;
  padding: 0 5%;
  /* stageMode "dark": content sits on the dark stage, so it takes the SURFACE colour. */
  color: var(--color-surface, #f5f5f0);
  pointer-events: none;
}
@supports (color: oklch(from red min(l, 1 - l) c h)) {
  .gro-content {
    --gro-ds-ink: oklch(from var(--color-surface) max(l, 1 - l) c h);
    color: var(--gro-ds-ink, #f5f5f0);
  }
}
.gro-title {
  font-family: 'Geist', sans-serif;
  font-size: clamp(2.5rem, 6vw, 5rem);
  font-weight: 700;
  letter-spacing: -0.04em;
  line-height: 1.05;
  margin: 0 0 1rem;
}
.gro-sub {
  font-family: 'Geist Mono', monospace;
  font-size: 0.8rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  opacity: 0.6;
}
@media (prefers-reduced-motion: reduce) {
  .gro-canvas { display: none; }
}

━━ JavaScript

(function(){
  const stage = document.querySelector('.gro-stage');
  const canvas = document.querySelector('.gro-canvas');
  if (!stage || !canvas) return;
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  if (typeof THREE === 'undefined') return;

  // ── Token resolution ───────────────────────────────────────────────────
  // Canvas and WebGL take numbers, not var(). Resolve the tokens once, through the
  // browser: a hidden probe forces the cascade to compute the declaration, so
  // color-mix() and any token indirection arrive here already serialized as
  // rgb()/rgba(). With no tokens declared every fallback is the pattern's original
  // literal, so the painted result is unchanged.
  const TOKEN_ROOT = (stage) || document.body;
  function computed(prop, decl, fallback){
    const probe = document.createElement('span');
    probe.style.cssText = 'position:absolute;left:-9999px;width:0;height:0;';
    probe.style.setProperty(prop, decl);
    (TOKEN_ROOT || document.body).appendChild(probe);
    const v = getComputedStyle(probe)[prop];
    probe.remove();
    return v || fallback;
  }
  function rgbOf(decl, fallback){
    const v = computed('color', decl, null);
    if (!v) return fallback;
    // DO NOT parse the numbers out of this string. A color-mix(in oklab, ...) serializes
    // as 'oklab(L a b)', so a naive match() would read lightness as a red channel and
    // every derived colour would collapse to near-black. Paint it and read the real
    // 8-bit sRGB back instead — that works for rgb(), oklab(), lab(), color(), any of it.
    try {
      const c = document.createElement('canvas');
      c.width = 1; c.height = 1;
      const x = c.getContext('2d', { willReadFrequently: true });
      x.fillStyle = '#000';
      x.fillStyle = v;               // an unparseable value leaves fillStyle at #000
      x.fillRect(0, 0, 1, 1);
      const d = x.getImageData(0, 0, 1, 1).data;
      return [ d[0], d[1], d[2] ];
    } catch (e) {}
    // No 2D context (or a hostile embed). Fall back to the legacy rgb() shape, which is
    // still what a plain var() with a hex fallback resolves to.
    const m = String(v).match(/^rgba?\(([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
    return m ? [ +m[1], +m[2], +m[3] ] : fallback;
  }
  function rgb01(decl, fallback){
    const c = rgbOf(decl, null);
    return c ? [ c[0]/255, c[1]/255, c[2]/255 ] : fallback;
  }
  function hexOf(decl, fallback){
    const c = rgbOf(decl, null);
    return c ? ((c[0]|0) << 16) + ((c[1]|0) << 8) + (c[2]|0) : fallback;
  }
  // The renderer's ground and the scene's brand colours come from the stage's own custom
  // properties, so the WebGL field and the CSS degradation backdrop behind it are one
  // palette. tokColor() is what the verbatim factories call in place of a vec3 literal.
  const CLEAR = hexOf('var(--gro-ink, #0a0a0a)', 0x0a0a0a);
  function tokColor(decl, r, g, b){
    const c = rgb01(decl, [r, g, b]);
    return new THREE.Color(c[0], c[1], c[2]);
  }

  // OBJECTS[1] — "Glass Refraction Orb". Scene factory VERBATIM from the
  // source sheet; returns {scene, camera, update(t), resize(w, h)} where t is ms.
  function build(w, h){
   const sc=new THREE.Scene();const cam=new THREE.PerspectiveCamera(45,w/h,0.1,100);cam.position.z=4.2;
   const u={u_time:{value:0},
     BODY:{value:tokColor('var(--gro-body, #080a0f)',0.03,0.04,0.06)},
     WARM:{value:tokColor('var(--gro-warm, #c8793a)',0.784,0.475,0.227)},
     BRIGHT:{value:tokColor('var(--gro-bright, #c8f564)',0.784,0.961,0.392)}};
   const mat=new THREE.ShaderMaterial({uniforms:u,vertexShader:`varying vec3 vN;varying vec3 vV;varying vec3 vP;
     void main(){vN=normalize(normalMatrix*normal);vec4 mv=modelViewMatrix*vec4(position,1.0);
     vV=normalize(-mv.xyz);vP=position;gl_Position=projectionMatrix*mv;}`,
     fragmentShader:`uniform float u_time;uniform vec3 BODY;uniform vec3 WARM;uniform vec3 BRIGHT;
     varying vec3 vN;varying vec3 vV;varying vec3 vP;
     void main(){float f=pow(1.0-max(dot(normalize(vN),normalize(vV)),0.0),3.0);
     vec3 inner=0.5+0.5*cos(vP*3.0+u_time+vec3(0.0,2.0,4.0));
     inner=mix(WARM,BRIGHT,inner.r)*0.5;
     vec3 col=mix(BODY,inner,0.6);
     col+=BRIGHT*f;gl_FragColor=vec4(col,1.0);}`});
   const m=new THREE.Mesh(new THREE.SphereGeometry(1.4,64,64),mat);sc.add(m);
   return {scene:sc,camera:cam,update(t){u.u_time.value=t*0.001;m.rotation.y=t*0.00025;},
     resize(w,h){cam.aspect=w/h;cam.updateProjectionMatrix();}};
  }

  // ── Renderer ───────────────────────────────────────────────────────────
  let renderer;
  try {
    renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: false });
  } catch (e) {
    // No WebGL — leave the canvas unpainted so the stage's static gradient shows.
    canvas.style.display = 'none';
    return;
  }
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setClearColor(CLEAR, 1);

  function size(){
    return {
      w: stage.clientWidth || window.innerWidth,
      h: stage.clientHeight || window.innerHeight
    };
  }

  const dim = size();
  renderer.setSize(dim.w, dim.h, false);

  let gl;
  try {
    gl = build(dim.w, dim.h);
  } catch (e) {
    canvas.style.display = 'none';
    return;
  }

  function resize(){
    const d = size();
    renderer.setSize(d.w, d.h, false);
    try { if (gl && gl.resize) gl.resize(d.w, d.h); } catch (e) {}
  }
  window.addEventListener('resize', resize);

  // ── Context loss ───────────────────────────────────────────────────────
  // Browsers cap live WebGL contexts (commonly ~16). A long catalog page can
  // exceed that and this context may be force-lost. preventDefault() is what
  // makes restoration possible at all; hiding the canvas reveals the static
  // gradient painted behind it, so a lost stage degrades to the fallback field
  // instead of a black rectangle.
  let rafId = 0;
  let lost = false;

  canvas.addEventListener('webglcontextlost', function (e) {
    e.preventDefault();
    lost = true;
    if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
    canvas.style.opacity = '0';
  }, false);

  canvas.addEventListener('webglcontextrestored', function () {
    try {
      renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
      renderer.setClearColor(CLEAR, 1);
      const d = size();
      renderer.setSize(d.w, d.h, false);
      // Geometry, lights and instanced buffers do not survive the loss —
      // rebuild the scene rather than re-uploading a stale graph.
      gl = build(d.w, d.h);
      lost = false;
      canvas.style.opacity = '1';
      if (!rafId) rafId = requestAnimationFrame(frame);
    } catch (e) {
      // Rebuild failed — stay on the static gradient.
      canvas.style.opacity = '0';
    }
  }, false);

  // ── Loop ───────────────────────────────────────────────────────────────
  // Honors the showcase pause bridge: when the stage is offscreen the GPU work
  // is skipped. Rotation is driven from elapsed time, so a resume continues from
  // the current angle rather than snapping back.
  const start = performance.now();
  function frame(now){
    rafId = 0;
    if (!lost && window.__cpPaused !== true) {
      const t = now - start;
      try { gl.update(t); renderer.render(gl.scene, gl.camera); } catch (e) {}
    }
    if (!lost) rafId = requestAnimationFrame(frame);
  }
  rafId = requestAnimationFrame(frame);
})();
Glass refraction orb · CreativePower