Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Glass torus hero" for my site using the Enrichr Library element at https://enrichr.dev/elements/glass-torus-hero.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/glass-torus-hero.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- lines: Motion, Design, Library
- font: parkinsans
- speed: 1
- follow: 1
- size: 9
- ior: 1.45
- thickness: 0.7
- dispersion: 4
- tint: #ffffff
- background: #fbf8f1
- ink: #0c1e18
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/glass-torus-hero.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-gtk">
<canvas class="ef-gtk__c" role="img" aria-label="A glossy glass knot turns slowly in front of the headline, bending and splitting the big black letters behind it"></canvas>
<h2 class="ef-gtk__h"><span class="ef-gtk__l">Motion</span> <span class="ef-gtk__l">Design</span> <span class="ef-gtk__l">Library</span></h2>
<nav class="ef-gtk__nav" aria-label="Example navigation">
<a class="ef-gtk__logo" href="#"><svg class="ef-gtk__mark" viewBox="0 0 50 50" aria-hidden="true" focusable="false"><path fill="currentColor" d="m34.213 25.361 c0 2.655 1.381 4.808 3.086 4.808 0.781 0 1.494 -0.452 2.038 -1.198 0.267 -0.366 0.688 -0.589 1.142 -0.589 h0.054 c0.774 0 1.401 0.627 1.401 1.401 v10.65 h-10.651 c-0.774 0 -1.401 0.627 -1.401 1.401 v0.054 c0 0.453 0.222 0.874 0.589 1.141 0.746 0.544 1.198 1.257 1.198 2.038 0 1.705 -2.152 3.086 -4.808 3.086 -2.655 0 -4.808 -1.381 -4.808 -3.086 0 -0.781 0.452 -1.494 1.198 -2.038 0.366 -0.267 0.589 -0.688 0.589 -1.142 v-0.054 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-10.65 v-10.651 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-0.054 c-0.453 0 -0.875 0.222 -1.142 0.589 -0.543 0.746 -1.257 1.198 -2.038 1.198 -1.705 0 -3.086 -2.152 -3.086 -4.808 0 -2.655 1.381 -4.808 3.086 -4.808 0.781 0 1.495 0.452 2.038 1.198 0.267 0.366 0.688 0.589 1.141 0.589 h0.054 c0.774 0 1.401 -0.627 1.401 -1.401 v-10.65 h10.65 c0.774 0 1.401 -0.627 1.401 -1.401 v-0.054 c0 -0.453 -0.222 -0.875 -0.589 -1.142 -0.746 -0.543 -1.198 -1.257 -1.198 -2.038 0 -1.705 2.152 -3.086 4.808 -3.086 2.655 0 4.808 1.381 4.808 3.086 0 0.781 -0.452 1.495 -1.198 2.038 -0.366 0.267 -0.589 0.688 -0.589 1.141 v0.054 c0 0.774 0.627 1.401 1.401 1.401 h10.65 v10.65 c0 0.774 -0.627 1.401 -1.401 1.401 h-0.053 c-0.453 0 -0.874 -0.222 -1.141 -0.589 -0.544 -0.746 -1.257 -1.198 -2.038 -1.198 -1.705 0 -3.086 2.152 -3.086 4.808 z"/></svg><span>Enrichr</span></a>
<ul class="ef-gtk__links">
<li class="ef-gtk__li"><a class="ef-gtk__a" href="#">Work</a></li>
<li class="ef-gtk__li"><a class="ef-gtk__a" href="#">About</a></li>
<li class="ef-gtk__li"><a class="ef-gtk__a" href="#">Contact</a></li>
</ul>
</nav>
<div class="ef-gtk__meta"><span>Enrichr Library — 2026</span><span>Scroll to explore</span></div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-gtk { --ef-gtk-bg: #fbf8f1; --ef-gtk-ink: #0c1e18; container-type: inline-size; position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 26rem; overflow: hidden; border-radius: 1rem; background: var(--ef-gtk-bg); color: var(--ef-gtk-ink); font-family: 'Parkinsans', 'Space Grotesk', sans-serif; touch-action: pan-y; }
.ef-gtk .ef-gtk__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ef-gtk .ef-gtk__h { position: absolute; width: 1px; height: 1px; margin: 0; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; font-size: 1rem; opacity: 1; color: inherit; }
.ef-gtk .ef-gtk__l { display: block; }
.ef-gtk .ef-gtk__nav { position: absolute; top: 0; left: 0; right: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; padding: clamp(1.1rem, 3.9%, 1.75rem) clamp(1.3rem, 3.1%, 2.5rem); }
.ef-gtk .ef-gtk__logo { display: inline-flex; align-items: center; gap: 0.4rem; color: inherit; text-decoration: none; font-weight: 700; font-size: 1.125rem; letter-spacing: -0.02em; }
.ef-gtk .ef-gtk__mark { width: 1.45rem; height: 1.45rem; display: block; }
.ef-gtk .ef-gtk__links { display: flex; gap: clamp(1.1rem, 2.5%, 2rem); margin: 0; padding: 0; list-style: none; }
.ef-gtk .ef-gtk__li { margin: 0; padding: 0; list-style: none; opacity: 1; font-size: 0.8125rem; }
.ef-gtk .ef-gtk__a { color: inherit; text-decoration: none; font-size: 0.8125rem; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; opacity: 0.7; transition: opacity 0.25s ease; }
.ef-gtk .ef-gtk__a:hover, .ef-gtk .ef-gtk__a:focus-visible { opacity: 1; }
.ef-gtk .ef-gtk__a:focus-visible, .ef-gtk .ef-gtk__logo:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; border-radius: 2px; }
.ef-gtk .ef-gtk__meta { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; padding: clamp(1.1rem, 3.9%, 1.75rem) clamp(1.3rem, 3.1%, 2.5rem); font-size: 0.75rem; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; opacity: 0.6; pointer-events: none; }
/* no WebGL: the headline becomes real, visible type on the plain background */
.ef-gtk.is-flat .ef-gtk__h { position: absolute; inset: 0; width: auto; height: auto; clip-path: none; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: clamp(2.5rem, 11cqw, 9rem); line-height: 0.98; text-transform: uppercase; letter-spacing: -0.02em; white-space: normal; }
.ef-gtk.is-flat.is-bebas .ef-gtk__h { font-family: 'Bebas Neue', 'Parkinsans', sans-serif; font-weight: 400; letter-spacing: 0; }JavaScript
// Enrichr Library · https://enrichr.dev
// Glass torus hero: a glossy glass torus knot floats in front of a huge three-line headline and bends it. Built with raw
// WebGL1, no three.js. The headline is drawn on a 2D canvas and shown full-screen as a texture. The knot is a real mesh
// (the same (2,3) torus knot, 300 x 48 segments) shaded like a clear physical glass material: for each pixel the view ray
// is refracted into the glass (index 1.45), carried "thickness" units through it, projected back to the screen and used to
// read the headline texture, once per colour channel with a slightly different index, so edges split into orange and blue
// (dispersion). A soft studio room with bright light panels is worked out in the shader and mirrored on the surface with a
// Fresnel term, which gives the white streaks. The knot turns on two axes (0.35 and 0.5 rad a second) and leans toward the
// pointer. Reduced motion draws one still pose. The loop pauses off screen and in hidden tabs. No WebGL: plain headline.
// mount(root, { lines (comma separated), font ("parkinsans" | "bebas"), speed (spin), follow (pointer lean), size (% of
// the view), ior, thickness, dispersion, tint (glass colour), background, ink })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-gtk");
var canvas = root.querySelector(".ef-gtk__c");
var head = root.querySelector(".ef-gtk__h");
var opts = {}, raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0, clock = 0;
var W = 1, H = 1, dpr = 1, px = 0, py = 0, lines = [];
var CAMERA_Z = 5, FOV = 45, TAN = Math.tan(FOV * Math.PI / 360);
var tcan = document.createElement("canvas"), tctx = tcan.getContext("2d");
var pcan = document.createElement("canvas"), pctx = pcan.getContext("2d"), potW = 1, potH = 1, maxTex = 2048;
var gl = null, bgProg = null, kProg = null, bgBuf = null, vBuf = null, iBuf = null, tex = null, mtex = null, nIdx = 0, ub = {}, uk = {};
var VS_BG = "attribute vec2 p;varying vec2 vUv;void main(){vUv=p*.5+.5;gl_Position=vec4(p,0.,1.);}";
var FS_BG = "precision mediump float;uniform sampler2D uTex;varying vec2 vUv;void main(){gl_FragColor=texture2D(uTex,vUv);}";
var VS_K = "attribute vec3 aP;attribute vec3 aN;uniform mat4 uM,uVP;uniform mat3 uR;varying vec3 vW,vN;" +
"void main(){vec4 w=uM*vec4(aP,1.);vW=w.xyz;vN=uR*aN;gl_Position=uVP*w;}";
// Physical glass, the way three.js MeshPhysicalMaterial does transmission + dispersion + IBL specular, with the
// RoomEnvironment studio (walls lit by one point light, six emissive panels) traced analytically instead of a cubemap.
var FS_K = [
"uniform sampler2D uTex;uniform mat4 uVP;uniform vec3 uCam,uTint;uniform float uThick,uIor,uDisp,uLodW,uLodOff;",
"varying vec3 vW,vN;",
"const vec3 BMIN=vec3(-16.6135,-4.4335,-13.5785),BMAX=vec3(15.0995,23.8715,15.0125),LP=vec3(.418,12.699,.3);",
"vec3 lin(vec3 c){return mix(c/12.92,pow((c+.055)/1.055,vec3(2.4)),step(.04045,c));}",
"vec3 srgb(vec3 c){c=clamp(c,0.,1.);return mix(c*12.92,1.055*pow(c,vec3(1./2.4))-.055,step(.0031308,c));}",
// refract the view ray, carry it through the glass, project the exit point and read the headline there. The mip level
// follows three.js: log2(buffer width) * roughness * clamp(2 ior - 2, 0, 1), plus half a level for its bicubic filter.
"vec3 tr(vec3 n,vec3 v,float ior,float rough){vec3 r=normalize(refract(-v,n,1./ior))*uThick;vec4 c=uVP*vec4(vW+r,1.);vec2 uv=c.xy/c.w*.5+.5;",
"#ifdef LOD",
" return lin(texture2DLodEXT(uTex,uv,max(0.,uLodW*rough*clamp(ior*2.-2.,0.,1.)+uLodOff+.5)).rgb);",
"#else",
" return lin(texture2D(uTex,uv).rgb);",
"#endif",
"}",
"float panel(vec3 r,vec3 c,vec3 h,vec3 ax,float tw,float sf){",
" float d=dot(r,ax),cc=dot(c,ax);if(d*cc<=0.)return 0.;float t=cc/d;if(t>tw)return 0.;",
" vec3 e=abs(r*t-c)-h;e=e*(1.-ax)-ax*1e3;float m=max(e.x,max(e.y,e.z));float s=sf*t;",
" return 1.-smoothstep(-s,s,m);}",
// three.js RoomEnvironment seen from its middle: a white room lit by one point light (900 cd, range 28), six bright panels
"float env(vec3 r,float sf){",
" vec3 sr=step(0.,r)*2.-1.;vec3 rr=sr*max(abs(r),vec3(1e-4));",
" vec3 tt=mix(BMIN,BMAX,step(0.,rr))/rr;float t=min(tt.x,min(tt.y,tt.z));vec3 p=rr*t;",
" vec3 nI=tt.x<=t?vec3(-sr.x,0.,0.):(tt.y<=t?vec3(0.,-sr.y,0.):vec3(0.,0.,-sr.z));",
" vec3 L=LP-p;float d=length(L);float k=clamp(1.-pow(d/28.,4.),0.,1.);",
" float c=900./max(d*d,.01)*k*k*max(dot(nI,L/d),0.)/3.14159265;",
" c=mix(c,50.,panel(rr,vec3(-16.116,10.87,8.208),vec3(.05,1.214,1.3695),vec3(1.,0.,0.),t,sf));",
" c=mix(c,50.,panel(rr,vec3(-16.109,14.521,-8.207),vec3(.05,1.2125,1.3755),vec3(1.,0.,0.),t,sf));",
" c=mix(c,17.,panel(rr,vec3(14.904,8.698,-1.832),vec3(.075,2.1325,3.1655),vec3(1.,0.,0.),t,sf));",
" c=mix(c,43.,panel(rr,vec3(-.462,5.39,14.52),vec3(2.19,2.7205,.044),vec3(0.,0.,1.),t,sf));",
" c=mix(c,20.,panel(rr,vec3(3.235,7.986,-12.541),vec3(1.25,1.,.05),vec3(0.,0.,1.),t,sf));",
" c=mix(c,100.,panel(rr,vec3(0.,16.5,0.),vec3(.5,.05,.5),vec3(0.,1.,0.),t,sf));",
" return c;}",
"void main(){",
" vec3 n=normalize(vN),v=normalize(uCam-vW);",
" float rough=.0525;",
"#ifdef LOD",
" vec3 dxy=max(abs(dFdx(n)),abs(dFdy(n)));rough=min(1.,rough+max(max(dxy.x,dxy.y),dxy.z));",
"#endif",
" float hs=(uIor-1.)*.025*uDisp;",
" vec3 T=vec3(tr(n,v,uIor-hs,rough).r,tr(n,v,uIor,rough).g,tr(n,v,uIor+hs,rough).b);",
" float nv=clamp(dot(n,v),0.,1.);vec4 q=rough*vec4(-1.,-.0275,-.572,.022)+vec4(1.,.0425,1.04,-.04);",
" float a=min(q.x*q.x,exp2(-9.28*nv))*q.x+q.y;vec2 fab=vec2(-1.04,1.04)*a+q.zw;",
" float f0=pow((uIor-1.)/(uIor+1.),2.);float F=f0*fab.x+fab.y;",
" vec3 col=(1.-F)*T*lin(uTint)+F*env(reflect(-v,n),.04+rough*.5);",
" gl_FragColor=vec4(srgb(col),1.);}"
].join("\n");
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
function rgb(h, d) { var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(m ? m[1] : d, 16); return [(v >> 16) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255]; }
// TorusKnotGeometry(1, 0.3, 300, 48, 2, 3), the same vertices, normals and winding as three.js
function knot() {
var TS = 300, RS = 48, P = 2, Q = 3, R = 1, TUBE = 0.3;
var vtx = new Float32Array((TS + 1) * (RS + 1) * 6), idx = new Uint16Array(TS * RS * 6), k = 0;
function curve(u) { var cs = Math.cos(Q / P * u); return [R * (2 + cs) * 0.5 * Math.cos(u), R * (2 + cs) * Math.sin(u) * 0.5, R * Math.sin(Q / P * u) * 0.5]; }
function cross(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; }
function norm(a) { var l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
for (var i = 0; i <= TS; i++) {
var u = i / TS * P * Math.PI * 2, P1 = curve(u), P2 = curve(u + 0.01);
var T = [P2[0] - P1[0], P2[1] - P1[1], P2[2] - P1[2]], N = [P2[0] + P1[0], P2[1] + P1[1], P2[2] + P1[2]];
var B = cross(T, N); N = cross(B, T); B = norm(B); N = norm(N);
for (var j = 0; j <= RS; j++) {
var v = j / RS * Math.PI * 2, cx = -TUBE * Math.cos(v), cy = TUBE * Math.sin(v);
var x = P1[0] + (cx * N[0] + cy * B[0]), y = P1[1] + (cx * N[1] + cy * B[1]), z = P1[2] + (cx * N[2] + cy * B[2]);
var n = norm([x - P1[0], y - P1[1], z - P1[2]]);
vtx[k++] = x; vtx[k++] = y; vtx[k++] = z; vtx[k++] = n[0]; vtx[k++] = n[1]; vtx[k++] = n[2];
}
}
k = 0;
for (var jj = 1; jj <= TS; jj++) {
for (var ii = 1; ii <= RS; ii++) {
var a = (RS + 1) * (jj - 1) + (ii - 1), b = (RS + 1) * jj + (ii - 1), c = (RS + 1) * jj + ii, d = (RS + 1) * (jj - 1) + ii;
idx[k++] = a; idx[k++] = b; idx[k++] = d; idx[k++] = b; idx[k++] = c; idx[k++] = d;
}
}
return { v: vtx, i: idx };
}
function shader(type, src) { var s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null; }
function program(vs, fs, attrs) {
var v = shader(gl.VERTEX_SHADER, vs), f = shader(gl.FRAGMENT_SHADER, fs);
if (!v || !f) return null;
var p = gl.createProgram(); gl.attachShader(p, v); gl.attachShader(p, f);
attrs.forEach(function (a, i) { gl.bindAttribLocation(p, i, a); });
gl.linkProgram(p); gl.deleteShader(v); gl.deleteShader(f);
return gl.getProgramParameter(p, gl.LINK_STATUS) ? p : null;
}
function setup() {
gl = canvas.getContext("webgl", { antialias: true, alpha: false, premultipliedAlpha: false }) || canvas.getContext("experimental-webgl", { antialias: true, alpha: false });
if (!gl) return false;
// texture2DLodEXT + dFdx give the same softened see-through image as three.js; without them it is sharp but still works
var lodOk = !!(gl.getExtension("EXT_shader_texture_lod") && gl.getExtension("OES_standard_derivatives"));
var pre = lodOk ? "#extension GL_EXT_shader_texture_lod : enable\n#extension GL_OES_standard_derivatives : enable\n#define LOD 1\n" : "";
bgProg = program(VS_BG, FS_BG, ["p"]);
kProg = program(VS_K, pre + "precision highp float;\n" + FS_K, ["aP", "aN"]);
if (!kProg && lodOk) kProg = program(VS_K, "precision highp float;\n" + FS_K, ["aP", "aN"]);
if (!bgProg || !kProg) return false;
["uTex"].forEach(function (k) { ub[k] = gl.getUniformLocation(bgProg, k); });
["uM", "uVP", "uR", "uTex", "uCam", "uTint", "uThick", "uIor", "uDisp", "uLodW", "uLodOff"].forEach(function (k) { uk[k] = gl.getUniformLocation(kProg, k); });
maxTex = Math.min(2048, gl.getParameter(gl.MAX_TEXTURE_SIZE) || 2048);
mtex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, mtex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
bgBuf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, bgBuf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
var g = knot(); nIdx = g.i.length;
vBuf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vBuf); gl.bufferData(gl.ARRAY_BUFFER, g.v, gl.STATIC_DRAW);
iBuf = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, iBuf); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, g.i, gl.STATIC_DRAW);
tex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return true;
}
function fontFor(size) { return opts.font === "bebas" ? "400 " + size + "px 'Bebas Neue', 'Parkinsans', sans-serif" : "800 " + size + "px 'Parkinsans', 'Space Grotesk', sans-serif"; }
// The headline texture: each line is sized to fill 92% of the width, then the block is fitted into 78% of the height.
function drawText() {
tcan.width = Math.max(1, Math.round(W * dpr)); tcan.height = Math.max(1, Math.round(H * dpr));
tctx.setTransform(dpr, 0, 0, dpr, 0, 0);
tctx.fillStyle = opts.background; tctx.fillRect(0, 0, W, H);
tctx.fillStyle = opts.ink; tctx.textAlign = "center"; tctx.textBaseline = "middle";
var maxW = W * 0.92, maxH = H * 0.78, base = 100, gap = 0.98;
tctx.font = fontFor(base);
var sizes = lines.map(function (l) { return base * (maxW / Math.max(1, tctx.measureText(l).width)); });
var total = sizes.reduce(function (s, z) { return s + z * gap; }, 0);
var fit = Math.min(1, maxH / (total || 1)), y = H / 2 - total * fit / 2;
lines.forEach(function (l, i) {
var z = sizes[i] * fit;
tctx.font = fontFor(z);
y += z * gap / 2; tctx.fillText(l, W / 2, y); y += z * gap / 2;
});
if (!gl) return;
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, tcan);
// a power-of-two copy with mipmaps for the glass to look through (three.js reads a mipmapped transmission target)
potW = Math.min(maxTex, pow2(tcan.width)); potH = Math.min(maxTex, pow2(tcan.height));
if (pcan.width !== potW || pcan.height !== potH) { pcan.width = potW; pcan.height = potH; }
pctx.imageSmoothingEnabled = true; pctx.imageSmoothingQuality = "high";
pctx.drawImage(tcan, 0, 0, potW, potH);
gl.bindTexture(gl.TEXTURE_2D, mtex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, pcan);
gl.generateMipmap(gl.TEXTURE_2D);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
}
function pow2(n) { var p = 1; while (p < n) p *= 2; return p; }
function render(t) {
if (!gl || gl.isContextLost()) return;
var rx, ry;
if (reduce) { rx = 0.6; ry = 0.4; }
else { rx = t * 0.35 + py * 0.15 * opts.follow; ry = t * 0.5 + px * 0.2 * opts.follow; }
var aspect = W / H, visH = 2 * CAMERA_Z * TAN, visW = visH * aspect, s = Math.min(visW, visH) * opts.size / 100;
// model = translate(0,0,2.2) * rotateX(rx) * rotateY(ry) * scale(s) (three.js Euler order XYZ)
var a = Math.cos(rx), b = Math.sin(rx), c = Math.cos(ry), d = Math.sin(ry);
var R = [c, b * d, -a * d, 0, a, b, d, -b * c, a * c]; // column-major 3x3
var M = [R[0] * s, R[1] * s, R[2] * s, 0, R[3] * s, R[4] * s, R[5] * s, 0, R[6] * s, R[7] * s, R[8] * s, 0, 0, 0, 2.2, 1];
// view-projection: perspective(45deg, aspect, 0.1, 100) * translate(0,0,-5)
var f = 1 / TAN, nr = 0.1, fr = 100, A = (fr + nr) / (nr - fr), B = 2 * fr * nr / (nr - fr);
var VP = [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, A, -1, 0, 0, A * -CAMERA_Z + B, CAMERA_Z];
gl.viewport(0, 0, canvas.width, canvas.height);
gl.disable(gl.DEPTH_TEST); gl.depthMask(false); gl.disable(gl.CULL_FACE);
gl.useProgram(bgProg);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tex); gl.uniform1i(ub.uTex, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, bgBuf); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.disableVertexAttribArray(1);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.enable(gl.DEPTH_TEST); gl.depthMask(true); gl.clear(gl.DEPTH_BUFFER_BIT);
gl.enable(gl.CULL_FACE); gl.cullFace(gl.BACK); gl.frontFace(gl.CCW);
gl.useProgram(kProg);
gl.uniformMatrix4fv(uk.uM, false, M); gl.uniformMatrix4fv(uk.uVP, false, VP); gl.uniformMatrix3fv(uk.uR, false, R);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, mtex);
gl.uniform1i(uk.uTex, 0); gl.uniform3f(uk.uCam, 0, 0, CAMERA_Z);
gl.uniform1f(uk.uLodW, Math.log2(Math.max(2, canvas.width))); gl.uniform1f(uk.uLodOff, Math.log2(potW / Math.max(1, canvas.width)));
var tint = rgb(opts.tint, "ffffff"); gl.uniform3f(uk.uTint, tint[0], tint[1], tint[2]);
gl.uniform1f(uk.uThick, opts.thickness * s); gl.uniform1f(uk.uIor, opts.ior); gl.uniform1f(uk.uDisp, opts.dispersion);
gl.bindBuffer(gl.ARRAY_BUFFER, vBuf);
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0);
gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, iBuf);
gl.drawElements(gl.TRIANGLES, nIdx, gl.UNSIGNED_SHORT, 0);
}
function loop(now) {
raf = requestAnimationFrame(loop);
var dt = Math.min(0.05, (now - last) / 1000 || 0); last = now;
if (!visible || document.hidden) return;
clock += dt * opts.speed;
render(clock);
}
function size() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = Math.max(1, wrap.clientWidth); H = Math.max(1, wrap.clientHeight);
canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
drawText();
render(clock);
}
function move(e) {
var r = wrap.getBoundingClientRect();
px = (e.clientX - r.left) / Math.max(1, r.width) * 2 - 1;
py = (e.clientY - r.top) / Math.max(1, r.height) * 2 - 1;
}
function setLines() {
lines = String(opts.lines).split(",").map(function (s) { return s.trim().toUpperCase(); }).filter(Boolean).slice(0, 5);
if (!lines.length) lines = ["MOTION"];
head.textContent = "";
lines.forEach(function (l, i) {
var sp = document.createElement("span"); sp.className = "ef-gtk__l"; sp.textContent = l.charAt(0) + l.slice(1).toLowerCase();
head.appendChild(sp); if (i < lines.length - 1) head.appendChild(document.createTextNode(" "));
});
}
function update(partial) {
opts = Object.assign({ lines: "Motion, Design, Library", font: "parkinsans", speed: 1, follow: 1, size: 9, ior: 1.45, thickness: 0.7, dispersion: 4, tint: "#ffffff", background: "#fbf8f1", ink: "#0c1e18" }, opts, partial || {});
opts.speed = Math.max(0, num(opts.speed, 1)); opts.follow = num(opts.follow, 1); opts.size = Math.max(1, num(opts.size, 9));
opts.ior = Math.max(1, num(opts.ior, 1.45)); opts.thickness = Math.max(0, num(opts.thickness, 0.7)); opts.dispersion = Math.max(0, num(opts.dispersion, 4));
wrap.style.setProperty("--ef-gtk-bg", opts.background); wrap.style.setProperty("--ef-gtk-ink", opts.ink);
wrap.classList.toggle("is-bebas", opts.font === "bebas");
setLines();
if (destroyed) return;
drawText();
render(clock);
// the canvas cannot wait for a web font by itself: ask for it, then draw the headline again once it is ready
if (document.fonts && document.fonts.load) document.fonts.load(fontFor(100)).then(redraw, function () {});
}
function redraw() { if (!destroyed) { drawText(); render(clock); } }
var ok = setup();
if (!ok) { gl = null; wrap.classList.add("is-flat"); canvas.style.display = "none"; }
update(o);
if (!ok) return { update: update, destroy: function () { destroyed = true; } };
size();
if (document.fonts && document.fonts.ready) document.fonts.ready.then(redraw);
wrap.addEventListener("pointermove", move);
if (!reduce) {
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
last = performance.now(); raf = requestAnimationFrame(loop);
}
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) size(); }); ro.observe(wrap); }
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf);
if (io) io.disconnect();
if (ro) ro.disconnect();
wrap.removeEventListener("pointermove", move);
if (gl) {
gl.deleteBuffer(bgBuf); gl.deleteBuffer(vBuf); gl.deleteBuffer(iBuf); gl.deleteTexture(tex); gl.deleteTexture(mtex);
gl.deleteProgram(bgProg); gl.deleteProgram(kProg);
var lose = gl.getExtension("WEBGL_lose_context"); if (lose) lose.loseContext();
}
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"lines":"Motion, Design, Library","font":"parkinsans","speed":1,"follow":1,"size":9,"ior":1.45,"thickness":0.7,"dispersion":4,"tint":"#ffffff","background":"#fbf8f1","ink":"#0c1e18"});How it works
The headline is drawn once onto a 2D canvas: each line is sized to fill 92% of the width, then the block is scaled to fit 78% of the height. WebGL shows that canvas full size, then draws a torus knot mesh built in JavaScript with the same (2, 3) curve and 300 by 48 segments that three.js uses. For every pixel of the knot the shader bends the view ray into the glass with refract(), carries it the glass thickness through, projects that point back onto the screen and reads the headline there, once per colour channel with a slightly different index, which splits edges into orange and blue. The read comes from a mipmapped copy at a level set by roughness, as in three.js, so what you see through the glass is softly blurred. A studio room (white walls lit by one lamp, six bright panels) is traced in the shader and mirrored on the surface with a Fresnel term, which gives the white streaks. The knot turns 0.35 and 0.5 radians a second on two axes and leans up to 0.15 and 0.2 radians toward the pointer.
Accessibility and performance
The canvas has a text label, and the headline is also real text in a visually hidden heading, so screen readers get the words. With prefers-reduced-motion it draws one still pose and never animates. The navigation links are real links with visible focus rings. Nothing here needs the pointer. Without WebGL the headline shows as plain large type.
Two draws a frame: the headline texture and the knot (about 29,000 triangles). The glass shader does three texture reads and a short room trace, only on the pixels the knot covers. The headline texture is redrawn only when the text, colours, font or size change. Pixel ratio is capped at 2, the mipmapped copy at 2048 px, and the loop pauses off screen and in hidden tabs. destroy() frees the buffers and loses the WebGL context.
| Section | Hero |
|---|---|
| Techniques | WebGL, Pointer interaction |
| Stack | Vanilla JS, WebGL |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Glass Hero by Enrichr on CodePen. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Does it need three.js?
No. The original used three.js with a physical glass material (transmission and dispersion) and a studio environment map. Here the knot mesh is generated in JavaScript and the glass maths (refraction, dispersion, Fresnel, the studio reflections) is written out in one raw WebGL1 fragment shader.
How do I change the words?
Set Lines to up to five comma-separated words. Each line is sized to fill the width, so short words come out taller. The canvas waits for Parkinsans or Bebas Neue to load and then draws the headline again.
Can the glass be coloured?
Yes. Glass tint multiplies the light that comes through the knot: lime (#e6ff55) gives lime glass while the reflections stay white. On a dark background, set the headline colour to lime or cream.
Why is the text through the glass blurred and split?
That is the material. three.js reads the background at a blurrier mipmap level for glass, and dispersion uses a slightly different index for red, green and blue. Set Dispersion to 0 for a clean refraction, or Glass thickness lower for a gentler bend.