Refraction
A height field is evaluated per pixel, its gradient gives a surface normal by finite differences, and that normal refracts the lookup into the backdrop — real refraction maths on a single quad, with no mesh to subdivide and no resolution at which facets appear. The specular highlight is computed from the same normal, so the light moves correctly as the surface deforms; a distortion effect with a separately-animated shine reads as two effects happening near each other rather than as glass.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Refraction } from "@/components/refraction"; <Refraction />Customise
How far the surface deforms — effectively how thick the glass is.
0 is colourless glass. Higher splits the channels like a prism.
Flattens the normal, giving a more frosted surface.
The component
components/craft/3d/Refraction.tsx
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react";import { createPointerSource } from "../lib/pointerSource"; /** * A sheet of moulded glass over a colour field — the backdrop bends and * separates through it as the pointer moves. * * The surface is never modelled as geometry. A height field is evaluated per * pixel, its gradient gives a surface normal by finite differences, and the * normal refracts the lookup into the backdrop. That is real refraction maths * on a single quad, with no mesh to subdivide and no resolution at which the * facets become visible. * * Specular highlights come from the same normal, so the light moves correctly * as the surface deforms. That coupling is what sells it as glass; a * distortion effect with a separately-animated shine reads as two effects * happening near each other. */ const VERTEX = `attribute vec2 position;void main() { gl_Position = vec4(position, 0.0, 1.0); }`; const FRAGMENT = `precision highp float; uniform vec2 uResolution;uniform float uTime;uniform vec2 uPointer;uniform float uThickness;uniform float uDispersion;uniform float uRoughness;uniform vec3 uColorA;uniform vec3 uColorB; /* The backdrop being refracted. Generated rather than sampled from a texture, so the component needs no image and no second render pass. */vec3 backdrop(vec2 uv) { float bands = sin(uv.x * 9.0 + uTime * 0.35) * 0.5 + 0.5; float glow = smoothstep(0.85, 0.0, distance(uv, vec2(0.35, 0.6))); vec3 base = mix(uColorB, uColorA, bands * 0.65 + glow * 0.5); /* A faint grid, because a flat gradient shows no distortion — you need straight lines to SEE that the glass is bending anything. */ vec2 grid = abs(fract(uv * 14.0) - 0.5); float lines = smoothstep(0.48, 0.5, max(grid.x, grid.y)); return base + lines * 0.06;} /* Height field: a couple of drifting lobes plus a bulge under the pointer. */float height(vec2 p) { float h = 0.0; h += sin(p.x * 3.4 + uTime * 0.5) * cos(p.y * 2.9 - uTime * 0.42) * 0.5; h += sin(length(p - vec2(0.5)) * 7.0 - uTime * 0.8) * 0.25; float bulge = smoothstep(0.42, 0.0, distance(p, uPointer)); h += bulge * 1.6; return h * uThickness;} void main() { vec2 uv = gl_FragCoord.xy / uResolution; float aspect = uResolution.x / uResolution.y; vec2 p = vec2(uv.x * aspect, uv.y); vec2 pointer = vec2(uPointer.x * aspect, uPointer.y); /* Surface normal by central differences. The epsilon is in the same units as p, so the normal stays consistent across aspect ratios. */ float e = 0.004; float hx = height(p + vec2(e, 0.0)) - height(p - vec2(e, 0.0)); float hy = height(p + vec2(0.0, e)) - height(p - vec2(0.0, e)); vec3 normal = normalize(vec3(-hx, -hy, uRoughness)); /* Refract the lookup. Each channel bends by a slightly different amount — that IS dispersion, the reason a prism makes a rainbow. */ vec2 bend = normal.xy * 0.09; vec3 colour = vec3( backdrop(uv + bend * (1.0 + uDispersion * 0.35)).r, backdrop(uv + bend).g, backdrop(uv + bend * (1.0 - uDispersion * 0.35)).b ); /* Specular from the SAME normal, so the highlight tracks the deformation. */ vec3 light = normalize(vec3(0.4, 0.7, 0.6)); float spec = pow(max(dot(normal, light), 0.0), 28.0); colour += spec * 0.55; /* Fresnel: glass is more mirror-like at grazing angles, which is what gives the edges of a lens their bright rim. */ float fresnel = pow(1.0 - abs(normal.z), 3.0); colour += fresnel * 0.16; gl_FragColor = vec4(colour, 1.0);}`; function compile(gl: WebGLRenderingContext, type: number, src: string) { const s = gl.createShader(type); if (!s) return null; gl.shaderSource(s, src); gl.compileShader(s); return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;} function toRgb(hex: string): [number, number, number] { const m = /^#?([\da-f]{6})$/i.exec(hex.trim()); if (!m) return [0, 0, 0]; const n = parseInt(m[1], 16); return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];} export interface RefractionProps { colorA?: string; colorB?: string; /** How much the surface deforms — effectively how thick the glass is. */ thickness?: number; /** Channel separation. 0 is colourless glass. */ dispersion?: number; /** Higher flattens the normal, giving a softer, more frosted surface. */ roughness?: number; still?: boolean; className?: string;} export function Refraction({ colorA = "#c3f53c", colorB = "#0a0b0e", thickness = 1, dispersion = 1, roughness = 1.4, still = false, className = "",}: RefractionProps) { const ref = useRef<HTMLCanvasElement>(null); useEffect(() => { const canvas = ref.current; if (!canvas) return; const gl = canvas.getContext("webgl", { alpha: false, antialias: false }); if (!gl) return; const vs = compile(gl, gl.VERTEX_SHADER, VERTEX); const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT); if (!vs || !fs) return; const program = gl.createProgram(); if (!program) return; gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return; gl.useProgram(program); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const position = gl.getAttribLocation(program, "position"); gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0); const uniform = (n: string) => gl.getUniformLocation(program, n); const uResolution = uniform("uResolution"); const uTime = uniform("uTime"); const uPointer = uniform("uPointer"); gl.uniform1f(uniform("uThickness"), thickness); gl.uniform1f(uniform("uDispersion"), dispersion); gl.uniform1f(uniform("uRoughness"), roughness); gl.uniform3fv(uniform("uColorA"), toRgb(colorA)); gl.uniform3fv(uniform("uColorB"), toRgb(colorB)); const reduced = still || (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches); // Owned by this effect run — the canvas keeps its dimensions across // re-runs while a new program's uniforms start at zero, so comparing // against the DOM would skip the upload entirely. let uploadedWidth = 0; let uploadedHeight = 0; const resize = () => { // Capped at 1.5: this shader evaluates the height field five times per // pixel, so resolution is the dominant cost. const dpr = Math.min(devicePixelRatio || 1, 1.5); const w = Math.floor(canvas.clientWidth * dpr); const h = Math.floor(canvas.clientHeight * dpr); if (!w || !h) return; if (w === uploadedWidth && h === uploadedHeight) return; uploadedWidth = w; uploadedHeight = h; canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); gl.uniform2f(uResolution, w, h); }; /* On a phone there is no cursor to follow, so the source drifts the position along a slow path instead — the glass stays alive rather than freezing into a still frame. Tapping still moves it. */ const pointer = createPointerSource(canvas); let currentX = 0.5; let currentY = 0.5; const start = performance.now(); const draw = (now: number) => { resize(); // Eased, so the bulge trails the cursor like something with viscosity // rather than teleporting to it. const target = pointer.read((now - start) / 1000); currentX += (target.x - currentX) * 0.08; currentY += (target.y - currentY) * 0.08; gl.uniform2f(uPointer, currentX, currentY); gl.uniform1f(uTime, (now - start) / 1000); gl.drawArrays(gl.TRIANGLES, 0, 3); }; let frame = 0; const loop = (now: number) => { draw(now); frame = requestAnimationFrame(loop); }; draw(start); if (!reduced) frame = requestAnimationFrame(loop); const observer = new ResizeObserver(() => { resize(); if (reduced) draw(start); }); observer.observe(canvas); return () => { cancelAnimationFrame(frame); observer.disconnect(); pointer.dispose(); gl.deleteProgram(program); gl.deleteShader(vs); gl.deleteShader(fs); gl.deleteBuffer(buffer); }; }, [colorA, colorB, thickness, dispersion, roughness, still]); return <canvas ref={ref} aria-hidden className={`block h-full w-full ${className}`} />;} One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.
What it costs
- Weight
+2.9KB gzipped · no new dependencies
- Runs on
GPU
Work happens in a shader. The main thread only issues draw calls, so JavaScript stays free, but it costs GPU time and battery on mobile.
- Reduced motion
Draws one frame with the surface at rest and attaches no pointer listener. The glass is still there; it just stops moving.
- Accessibility
- Canvas is aria-hidden; decorative.
- Adds no focusable elements.
- Text placed over it needs a solid backing panel — a refracting surface has no stable contrast to test against.
- Watch out
- The most expensive shader here: the height field is evaluated five times per pixel for the normal. Device pixel ratio is capped at 1.5 for that reason.
- It refracts a GENERATED backdrop, not the DOM behind it. Refracting real page content needs a render target and a copy of the layout, which is a different and much heavier component.