import { useEffect, useRef } from "react"; const vertexShader = ` attribute vec2 position; void main() { gl_Position = vec4(position, 0.0, 1.0); } `; // Adapted from "The Universe Within" by BigWings (Martijn Steinrucken) // https://www.shadertoy.com/view/lscczl // License: CC BY-NC-SA 3.0 const fragmentShader = ` precision highp float; uniform float iTime; uniform vec2 iResolution; uniform float uDark; uniform vec3 uPointer; #define S(a, b, t) smoothstep(a, b, t) #define NUM_LAYERS 4. float N21(vec2 p) { vec3 a = fract(vec3(p.xyx) * vec3(213.897, 653.453, 253.098)); a += dot(a, a.yzx + 79.76); return fract((a.x + a.y) * a.z); } vec2 GetPos(vec2 id, vec2 offs, float t) { float n = N21(id + offs); float n1 = fract(n * 10.); float n2 = fract(n * 100.); float a = t + n; return offs + vec2(sin(a * n1), cos(a * n2)) * .4; } vec2 Attract(vec2 p, vec2 cursor, float strength) { vec2 delta = cursor - p; float d = length(delta); float pull = 1. - smoothstep(.08, 1.9, d); pull = pull * pull * (3. - 2. * pull); return p + delta * pull * .095 * strength; } float df_line(in vec2 a, in vec2 b, in vec2 p) { vec2 pa = p - a, ba = b - a; float h = clamp(dot(pa, ba) / dot(ba, ba), 0., 1.); return length(pa - ba * h); } float line(vec2 a, vec2 b, vec2 uv) { float r1 = .025; float r2 = .006; float d = df_line(a, b, uv); float d2 = length(a - b); float fade = S(1.5, .5, d2); fade += S(.05, .02, abs(d2 - .75)); return S(r1, r2, d) * fade; } // Unrolled for WebGL1 compatibility (no dynamic array indexing) float NetLayer(vec2 st, float n, float t, vec2 pointer, float pointerStrength) { vec2 cell = floor(st); vec2 id = cell + n; vec2 cursor = pointer - cell; st = fract(st) - .5; vec2 p0 = Attract(GetPos(id, vec2(-1,-1), t), cursor, pointerStrength); vec2 p1 = Attract(GetPos(id, vec2( 0,-1), t), cursor, pointerStrength); vec2 p2 = Attract(GetPos(id, vec2( 1,-1), t), cursor, pointerStrength); vec2 p3 = Attract(GetPos(id, vec2(-1, 0), t), cursor, pointerStrength); vec2 p4 = Attract(GetPos(id, vec2( 0, 0), t), cursor, pointerStrength); vec2 p5 = Attract(GetPos(id, vec2( 1, 0), t), cursor, pointerStrength); vec2 p6 = Attract(GetPos(id, vec2(-1, 1), t), cursor, pointerStrength); vec2 p7 = Attract(GetPos(id, vec2( 0, 1), t), cursor, pointerStrength); vec2 p8 = Attract(GetPos(id, vec2( 1, 1), t), cursor, pointerStrength); float m = 0.; float sparkle = 0.; float d; float s; float pulse; m += line(p4, p0, st); d = length(st-p0); s = (.005/(d*d)); s *= S(1.,.7,d); pulse = sin((fract(p0.x)+fract(p0.y)+t)*5.)*.4+.6; pulse = pow(pulse, 20.); sparkle += s * pulse; m += line(p4, p1, st); d = length(st-p1); s = (.005/(d*d)); s *= S(1.,.7,d); pulse = sin((fract(p1.x)+fract(p1.y)+t)*5.)*.4+.6; pulse = pow(pulse, 20.); sparkle += s * pulse; m += line(p4, p2, st); d = length(st-p2); s = (.005/(d*d)); s *= S(1.,.7,d); pulse = sin((fract(p2.x)+fract(p2.y)+t)*5.)*.4+.6; pulse = pow(pulse, 20.); sparkle += s * pulse; m += line(p4, p3, st); d = length(st-p3); s = (.005/(d*d)); s *= S(1.,.7,d); pulse = sin((fract(p3.x)+fract(p3.y)+t)*5.)*.4+.6; pulse = pow(pulse, 20.); sparkle += s * pulse; m += line(p4, p4, st); d = length(st-p4); s = (.005/(d*d)); s *= S(1.,.7,d); pulse = sin((fract(p4.x)+fract(p4.y)+t)*5.)*.4+.6; pulse = pow(pulse, 20.); sparkle += s * pulse; m += line(p4, p5, st); d = length(st-p5); s = (.005/(d*d)); s *= S(1.,.7,d); pulse = sin((fract(p5.x)+fract(p5.y)+t)*5.)*.4+.6; pulse = pow(pulse, 20.); sparkle += s * pulse; m += line(p4, p6, st); d = length(st-p6); s = (.005/(d*d)); s *= S(1.,.7,d); pulse = sin((fract(p6.x)+fract(p6.y)+t)*5.)*.4+.6; pulse = pow(pulse, 20.); sparkle += s * pulse; m += line(p4, p7, st); d = length(st-p7); s = (.005/(d*d)); s *= S(1.,.7,d); pulse = sin((fract(p7.x)+fract(p7.y)+t)*5.)*.4+.6; pulse = pow(pulse, 20.); sparkle += s * pulse; m += line(p4, p8, st); d = length(st-p8); s = (.005/(d*d)); s *= S(1.,.7,d); pulse = sin((fract(p8.x)+fract(p8.y)+t)*5.)*.4+.6; pulse = pow(pulse, 20.); sparkle += s * pulse; m += line(p1, p3, st); m += line(p1, p5, st); m += line(p7, p5, st); m += line(p7, p3, st); float sPhase = (sin(t + n) + sin(t * .1)) * .25 + .5; sPhase += pow(sin(t * .1) * .5 + .5, 50.) * 5.; m += sparkle * sPhase; return m; } void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = (fragCoord - iResolution.xy * .5) / iResolution.y; float t = iTime * .03; float s = sin(t); float c = cos(t); mat2 rot = mat2(c, -s, s, c); vec2 st = uv * rot; vec2 pointerUv = (uPointer.xy - iResolution.xy * .5) / iResolution.y; float m = 0.; for (float i = 0.; i < 1.; i += 1. / NUM_LAYERS) { float z = fract(t + i); float size = mix(15., 1., z); float fade = S(0., .6, z) * S(1., .8, z); vec2 pointerSt = pointerUv * rot * size; vec2 layerSt = st * size; float warp = 1. - smoothstep(.15, 2.7, length(layerSt - pointerSt)); warp = warp * warp * (3. - 2. * warp) * uPointer.z; layerSt -= (pointerSt - layerSt) * warp * .035; m += fade * NetLayer(layerSt, i, iTime * 0.3, pointerSt, uPointer.z); } float cursorLift = 1. - smoothstep(.04, .48, length(uv - pointerUv)); cursorLift = cursorLift * cursorLift * (3. - 2. * cursorLift) * uPointer.z; m *= 1. + cursorLift * 1.6; // Gray instead of rainbow vec3 baseCol = vec3(0.35) * uDark + vec3(0.12) * (1.0 - uDark); vec3 col = baseCol * m; // Vignette from original col *= 1. - dot(uv, uv); // Fade in then cap at the subtle sparkle state float tt = min(iTime, 5.0); col *= S(0., 20., tt); // Background vec3 bg = mix(vec3(1.0), vec3(0.0), uDark); if (uDark < 0.5) { col = bg - col * 1.2; } else { col = bg + col; } col = clamp(col, 0., 1.); fragColor = vec4(col, 1.); } void main() { mainImage(gl_FragColor, gl_FragCoord.xy); } `; export interface StarfieldBackgroundProps { className?: string; frameRate?: number; } export function StarfieldBackground({ className = "", frameRate = 30, }: StarfieldBackgroundProps) { const containerRef = useRef(null); const canvasRef = useRef(null); const rafRef = useRef(0); useEffect(() => { const canvasRaw = canvasRef.current; const containerRaw = containerRef.current; if (!canvasRaw || !containerRaw) return; // Non-null assertions: null branches exited above and these are // referenced inside closures where TypeScript loses the narrowing. const canvas: HTMLCanvasElement = canvasRaw; const container: HTMLElement = containerRaw; const glRaw = canvas.getContext("webgl", { alpha: false, antialias: false, preserveDrawingBuffer: false, }); if (!glRaw) return; const gl: WebGLRenderingContext = glRaw; function compileShader(type: number, src: string) { const shader = gl.createShader(type); if (!shader) return null; gl.shaderSource(shader, src); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(shader)); gl.deleteShader(shader); return null; } return shader; } const vs = compileShader(gl.VERTEX_SHADER, vertexShader); const fs = compileShader(gl.FRAGMENT_SHADER, fragmentShader); 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)) { console.error(gl.getProgramInfoLog(program)); gl.deleteProgram(program); gl.deleteShader(vs); gl.deleteShader(fs); return; } gl.useProgram(program); const buf = gl.createBuffer(); if (!buf) { gl.deleteProgram(program); gl.deleteShader(vs); gl.deleteShader(fs); return; } 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 pos = gl.getAttribLocation(program, "position"); gl.enableVertexAttribArray(pos); gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0); const uTime = gl.getUniformLocation(program, "iTime"); const uRes = gl.getUniformLocation(program, "iResolution"); const uDark = gl.getUniformLocation(program, "uDark"); const uPointer = gl.getUniformLocation(program, "uPointer"); const reducedMotionQuery = typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null; let reducedMotion = reducedMotionQuery?.matches ?? false; let dpr = 1; let hasPointer = false; let pointerX = 0; let pointerY = 0; let pointerStrength = 0; let targetX = 0; let targetY = 0; let targetStrength = 0; function readDarkMode() { return ( document.documentElement.classList.contains("dark") || document.documentElement.getAttribute("data-theme") === "dark" ); } let dark = readDarkMode(); function easePointer(allowPointer: boolean) { if (!allowPointer) { pointerStrength = 0; return; } pointerX += (targetX - pointerX) * 0.22; pointerY += (targetY - pointerY) * 0.22; pointerStrength += (targetStrength - pointerStrength) * 0.14; if (pointerStrength < 0.001 && targetStrength === 0) { pointerStrength = 0; } } function draw(timeSeconds: number, allowPointer = !reducedMotion) { easePointer(allowPointer); gl.uniform1f(uTime, timeSeconds); gl.uniform2f(uRes, canvas.width, canvas.height); gl.uniform1f(uDark, dark ? 1.0 : 0.0); gl.uniform3f(uPointer, pointerX, pointerY, pointerStrength); gl.drawArrays(gl.TRIANGLES, 0, 6); } function resize() { const w = container.clientWidth; const h = container.clientHeight; dpr = Math.min(window.devicePixelRatio, 1.5); canvas.width = w * dpr; canvas.height = h * dpr; canvas.style.width = w + "px"; canvas.style.height = h + "px"; gl.viewport(0, 0, canvas.width, canvas.height); if (!hasPointer) { pointerX = targetX = canvas.width * 0.5; pointerY = targetY = canvas.height * 0.5; } } function handlePointerMove(event: PointerEvent | MouseEvent) { const rect = container.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; const x = event.clientX - rect.left; const y = event.clientY - rect.top; const inside = x >= 0 && x <= rect.width && y >= 0 && y <= rect.height; hasPointer = true; targetX = x * dpr; targetY = (rect.height - y) * dpr; targetStrength = inside ? 1 : 0; } function fadePointer() { targetStrength = 0; } resize(); window.addEventListener("resize", resize); window.addEventListener("pointermove", handlePointerMove, { passive: true, }); window.addEventListener("mousemove", handlePointerMove, { passive: true, }); document.addEventListener("pointerleave", fadePointer, { passive: true, }); window.addEventListener("blur", fadePointer); const observer = new MutationObserver(() => { dark = readDarkMode(); if (reducedMotion) draw(20, false); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme"], }); const startTime = performance.now(); let lastFrame = 0; const frameBudget = 1000 / Math.max(1, frameRate); const reducedMotionStaticTime = 20; function render(now: number) { if (reducedMotion) { rafRef.current = 0; return; } rafRef.current = requestAnimationFrame(render); if (now - lastFrame < frameBudget) return; lastFrame = now; const rect = container.getBoundingClientRect(); if (rect.bottom < 0 || rect.top > window.innerHeight) return; draw((now - startTime) * 0.001); } function startAnimation() { if (!rafRef.current) { rafRef.current = requestAnimationFrame(render); } } function stopAnimation() { if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = 0; } } function handleReducedMotionChange() { reducedMotion = reducedMotionQuery?.matches ?? false; if (reducedMotion) { stopAnimation(); lastFrame = 0; draw(reducedMotionStaticTime, false); } else { startAnimation(); } } draw(reducedMotion ? reducedMotionStaticTime : 0, !reducedMotion); if (reducedMotionQuery) { reducedMotionQuery.addEventListener("change", handleReducedMotionChange); } if (!reducedMotion) startAnimation(); return () => { stopAnimation(); if (reducedMotionQuery) { reducedMotionQuery.removeEventListener( "change", handleReducedMotionChange, ); } observer.disconnect(); window.removeEventListener("resize", resize); window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("mousemove", handlePointerMove); document.removeEventListener("pointerleave", fadePointer); window.removeEventListener("blur", fadePointer); gl.deleteProgram(program); gl.deleteShader(vs); gl.deleteShader(fs); gl.deleteBuffer(buf); gl.getExtension("WEBGL_lose_context")?.loseContext(); }; }, [frameRate]); return ( ); }