Glass refraction orb
awwwardsDark 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);
})();