WebGL glass distortion with chromatic dispersion — add to your site in seconds
Click to ripple. Double-click to toggle grid.
Copy URL or Code
Paste to your AI coding assistant and customize:
Done. Your AI handles the rest.
Fully customizable. Supports 5 tile shapes with adjustable refraction, chromatic dispersion, breathing animation, and interactive wave effects. Works with any image.
"use client";
/**
* GLASS MOSAIC - WebGL Refraction Grid Effect
*
* AI AGENT QUICK REFERENCE:
* - Change tile shape: shape="circle" | "square" | "triangle"
* - Adjust distortion: refraction={0.8} (0-1.5)
* - Color fringing: dispersion={1.0} (0-2)
* - Animation speed: pulseSpeed={0.5} (0-4)
* - Tile density: tileSize={40} (8-200)
*/
import React, { useRef, useEffect, useState, useCallback } from "react";
export type TileShape = "hex" | "square" | "diamond" | "triangle" | "circle";
export interface GlassMosaicProps {
imageSrc: string;
shape?: TileShape;
tileSize?: number;
refraction?: number;
dispersion?: number;
pulseSpeed?: number;
animate?: boolean;
enableWave?: boolean;
showGrid?: boolean;
width?: number;
height?: number;
className?: string;
}
const VERT_SOURCE = `
attribute vec2 aVertex;
varying vec2 vTexCoord;
void main() {
vTexCoord = 0.5 * (aVertex + 1.0);
gl_Position = vec4(aVertex, 0.0, 1.0);
}
`;
const FRAG_SOURCE = `
#ifdef GL_ES
precision mediump float;
#endif
uniform vec2 uResolution;
uniform float uTime;
uniform vec2 uPointer;
uniform sampler2D uTexture;
uniform float uTileSize, uRefraction, uDispersion, uPulseRate, uPulseEnabled;
uniform int uTileShape;
uniform float uShowGrid, uWaveEnabled;
uniform vec2 uWaveOrigin;
uniform float uWaveStart;
varying vec2 vTexCoord;
vec2 hexToAxial(vec2 p, float size) {
return vec2((1.732051/3.0*p.x - 0.333333*p.y)/size, (0.666667*p.y)/size);
}
vec3 roundCube(vec3 c) {
float rx=floor(c.x+0.5), ry=floor(c.y+0.5), rz=floor(c.z+0.5);
float dx=abs(rx-c.x), dy=abs(ry-c.y), dz=abs(rz-c.z);
if(dx>dy && dx>dz) rx=-ry-rz; else if(dy>dz) ry=-rx-rz; else rz=-rx-ry;
return vec3(rx,ry,rz);
}
vec2 roundAxial(vec2 qr) { vec3 rc=roundCube(vec3(qr.x,-qr.x-qr.y,qr.y)); return vec2(rc.x,rc.z); }
vec2 axialToPixel(vec2 qr, float s) { return vec2(s*(1.732051*qr.x+0.866025*qr.y), s*1.5*qr.y); }
float distHex(vec2 p, float r) { p=abs(p); return max(dot(p,normalize(vec2(1.0,1.732051)))-r, p.x-r); }
float distSq(vec2 p, vec2 b) { vec2 d=abs(p)-b; return max(d.x,d.y); }
float distCirc(vec2 p, float r) { return length(p)-r; }
float distTri(vec2 p, float r) {
const float k=1.732051; p.x=abs(p.x)-r; p.y=p.y+r/k;
if(p.x+k*p.y>0.0) p=vec2(p.x-k*p.y,-k*p.x-p.y)/2.0;
p.x-=clamp(p.x,-2.0*r,0.0); return -length(p)*sign(p.y);
}
void findCenter(int sh, vec2 p, float t, out vec2 c, out vec2 l) {
if(sh==0) { vec2 qr=hexToAxial(p,t); c=axialToPixel(roundAxial(qr),t); l=p-c; }
else if(sh==3) { vec2 e1=vec2(t,0.0),e2=vec2(t*0.5,t*0.866025); float det=e1.x*e2.y-e1.y*e2.x;
vec2 k=vec2((p.x*e2.y-p.y*e2.x)/det,(-p.x*e1.y+p.y*e1.x)/det); c=e1*floor(k.x+0.5)+e2*floor(k.y+0.5); l=p-c; }
else { vec2 g=floor(p/t+0.5); c=g*t; l=p-c; }
}
float tileDist(int sh, vec2 l, float t) {
if(sh==0) return distHex(l,t*0.94);
if(sh==1) return distSq(l,vec2(t*0.48));
if(sh==2) { float a=0.7854; mat2 R=mat2(cos(a),-sin(a),sin(a),cos(a)); return distSq(R*l,vec2(t*0.48)); }
if(sh==3) return distTri(l,t*0.58);
return distCirc(l,t*0.52);
}
vec3 sample(vec2 uv) { return texture2D(uTexture,uv).rgb; }
void main() {
vec2 res=uResolution, frag=gl_FragCoord.xy, cen=frag-0.5*res, uv=vTexCoord;
vec2 ptr=uPointer; bool hasPtr=ptr.x>=0.0&&ptr.y>=0.0; vec2 ptrC=ptr-0.5*res;
float tile=max(6.0,uTileSize);
float t=uTime*uPulseRate;
float br=(uPulseEnabled>0.5)?sin(cen.x*0.0095+cen.y*0.014+t)*0.24:0.0;
float lt=tile*(1.0+br*0.18);
vec2 tc,lp; findCenter(uTileShape,cen,lt,tc,lp);
float d=tileDist(uTileShape,lp,lt), inside=smoothstep(0.0,1.4,-d);
float rad=clamp(length(lp)/(lt*0.94),0.0,1.0);
vec2 n=normalize(lp+1e-6);
float wave=0.0; vec2 wDir=vec2(0.0);
if(uWaveEnabled>0.5&&uWaveOrigin.x>=0.0) {
vec2 wc=uWaveOrigin-0.5*res; float wd=length(cen-wc), el=max(0.0,uTime-uWaveStart);
float env=exp(-wd*0.0055)*exp(-el*0.95); wave=sin(wd*0.058-el*5.8)*env; wDir=normalize(cen-wc+1e-6);
}
vec3 base=sample(uv);
float str=uRefraction*(1.0-pow(rad,1.35))*0.068;
vec2 off=n*str+wDir*(0.018*wave);
vec2 ca=off*(0.24*uDispersion); float cs=0.58*uDispersion;
vec3 glass=vec3(sample(uv+off+ca).r, sample(uv+off).g, sample(uv+off-ca*cs).b);
vec2 L=hasPtr?normalize(ptrC):normalize(vec2(0.65,0.95));
float spec=pow(max(0.0,dot(normalize(L),n)),13.5)*(1.0-rad);
glass+=vec3(0.98,0.95,0.88)*spec*0.42;
vec3 col=mix(base,glass,inside);
if(uShowGrid>0.5) { float g=smoothstep(1.4,0.0,abs(d)); col=mix(col,vec3(1.0),g*0.28); }
float vig=smoothstep(1.15,0.25,length((frag-0.5*res)/res.y));
col*=mix(0.88,1.0,vig);
gl_FragColor=vec4(col,1.0);
}
`;
const SHAPE_INDEX: Record<TileShape, number> = { hex:0, square:1, diamond:2, triangle:3, circle:4 };
export default function GlassMosaic({
imageSrc, shape="square", tileSize=56, refraction=0.5, dispersion=0.6,
pulseSpeed=1.0, animate=true, enableWave=true, showGrid=false,
width=600, height=400, className=""
}: GlassMosaicProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const glRef = useRef<WebGLRenderingContext|null>(null);
const progRef = useRef<WebGLProgram|null>(null);
const texRef = useRef<WebGLTexture|null>(null);
const frameRef = useRef<number>(0);
const [grid, setGrid] = useState(showGrid?1:0);
const [waveO, setWaveO] = useState<[number,number]>([-1,-1]);
const [waveT, setWaveT] = useState(0);
const ptrRef = useRef({x:-1,y:-1});
const t0 = useRef(performance.now());
const uRef = useRef<Record<string,WebGLUniformLocation|null>>({});
useEffect(() => {
const c=canvasRef.current; if(!c) return;
const gl=c.getContext("webgl"); if(!gl) return;
glRef.current=gl; gl.getExtension("OES_standard_derivatives");
const compile=(t:number,s:string)=>{const sh=gl.createShader(t);if(!sh)return null;gl.shaderSource(sh,s);gl.compileShader(sh);return gl.getShaderParameter(sh,gl.COMPILE_STATUS)?sh:null;};
const vs=compile(gl.VERTEX_SHADER,VERT_SOURCE), fs=compile(gl.FRAGMENT_SHADER,FRAG_SOURCE);
if(!vs||!fs) return;
const p=gl.createProgram(); if(!p) return;
gl.attachShader(p,vs); gl.attachShader(p,fs); gl.linkProgram(p);
if(!gl.getProgramParameter(p,gl.LINK_STATUS)) return;
progRef.current=p; gl.useProgram(p);
const buf=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buf);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);
const a=gl.getAttribLocation(p,"aVertex"); gl.enableVertexAttribArray(a); gl.vertexAttribPointer(a,2,gl.FLOAT,false,0,0);
["uResolution","uTime","uPointer","uTexture","uTileSize","uRefraction","uDispersion","uPulseRate","uPulseEnabled","uTileShape","uShowGrid","uWaveEnabled","uWaveOrigin","uWaveStart"].forEach(n=>{uRef.current[n]=gl.getUniformLocation(p,n);});
gl.uniform1i(uRef.current.uTexture,0);
const tex=gl.createTexture(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,tex);
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);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,true);
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,1,1,0,gl.RGBA,gl.UNSIGNED_BYTE,new Uint8Array([0,0,0,255]));
texRef.current=tex;
return ()=>{if(frameRef.current) cancelAnimationFrame(frameRef.current);};
}, []);
useEffect(() => {
const gl=glRef.current, tex=texRef.current; if(!gl||!tex||!imageSrc) return;
const img=new Image(); img.crossOrigin="anonymous";
img.onload=()=>{gl.bindTexture(gl.TEXTURE_2D,tex);gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,img);};
img.src=imageSrc;
}, [imageSrc]);
useEffect(() => {
const c=canvasRef.current, gl=glRef.current, p=progRef.current;
if(!c||!gl||!p) return;
const dpr=Math.min(2,devicePixelRatio||1);
c.width=Math.floor(width*dpr); c.height=Math.floor(height*dpr);
gl.viewport(0,0,c.width,c.height);
const render=()=>{
const el=(performance.now()-t0.current)*0.001, u=uRef.current;
gl.useProgram(p);
gl.uniform2f(u.uResolution,c.width,c.height);
gl.uniform1f(u.uTime,el);
gl.uniform2f(u.uPointer,ptrRef.current.x*dpr,ptrRef.current.y*dpr);
gl.uniform1f(u.uTileSize,tileSize);
gl.uniform1f(u.uRefraction,refraction);
gl.uniform1f(u.uDispersion,dispersion);
gl.uniform1f(u.uPulseRate,pulseSpeed);
gl.uniform1f(u.uPulseEnabled,animate?1:0);
gl.uniform1i(u.uTileShape,SHAPE_INDEX[shape]);
gl.uniform1f(u.uShowGrid,grid);
gl.uniform1f(u.uWaveEnabled,enableWave?1:0);
gl.uniform2f(u.uWaveOrigin,waveO[0]*dpr,waveO[1]*dpr);
gl.uniform1f(u.uWaveStart,waveT);
gl.drawArrays(gl.TRIANGLES,0,6);
frameRef.current=requestAnimationFrame(render);
};
render();
return ()=>{if(frameRef.current) cancelAnimationFrame(frameRef.current);};
}, [width,height,tileSize,refraction,dispersion,pulseSpeed,animate,shape,grid,enableWave,waveO,waveT]);
const toGL=useCallback((e:React.MouseEvent<HTMLCanvasElement>)=>{
const c=canvasRef.current; if(!c) return {x:-1,y:-1};
const r=c.getBoundingClientRect();
return {x:e.clientX-r.left, y:height-(e.clientY-r.top)};
},[height]);
return (
<canvas ref={canvasRef} className={className} style={{width,height,display:"block"}}
onMouseMove={(e)=>{ptrRef.current=toGL(e);}}
onMouseLeave={()=>{ptrRef.current={x:-1,y:-1};}}
onClick={(e)=>{if(!enableWave)return;const p=toGL(e);setWaveO([p.x,p.y]);setWaveT((performance.now()-t0.current)*0.001);}}
onDoubleClick={()=>{setGrid(g=>g>0.5?0:1);}}
/>
);
}
export { GlassMosaic };import GlassMosaic from "@/components/GlassMosaic";
import { useState } from "react";
// Basic usage
export default function Demo() {
return (
<GlassMosaic
imageSrc="/your-image.jpg"
shape="circle"
tileSize={56}
width={800}
height={600}
/>
);
}
// With shape switcher
export function WithShapes() {
const [shape, setShape] = useState("square");
return (
<div>
<GlassMosaic imageSrc="/image.jpg" shape={shape} width={800} height={600} />
<div className="flex gap-2 mt-4">
{["square", "triangle", "circle"].map(s => (
<button key={s} onClick={() => setShape(s)}
className={`px-3 py-1 rounded ${shape===s?"bg-black text-white":"bg-gray-200"}`}>
{s}
</button>
))}
</div>
</div>
);
}
// Custom settings
export function Custom() {
return (
<GlassMosaic
imageSrc="/image.jpg"
shape="circle"
tileSize={80} // Larger tiles
refraction={1.0} // More distortion
dispersion={1.2} // More color fringing
pulseSpeed={2.0} // Faster animation
animate={true}
enableWave={true}
width={1200}
height={800}
/>
);
}