Aurora
Depth here comes from layering rather than geometry. Several translucent ribbons are summed at different offsets, speeds and thicknesses, so overlaps brighten where curtains cross — which is what real aurora does, and why summing beats drawing one thick band. The result is tone-mapped rather than clamped: clamping makes crossings clip to flat white, losing exactly the detail the layering created.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Aurora } from "@/components/aurora"; <Aurora />Customise
Capped at 6 — GLSL ES 1.0 cannot loop to a uniform bound.
Brightness before tone mapping.
The component
components/craft/3d/Aurora.tsx
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react"; /** * Aurora curtains — layered ribbons of light, drifting and folding. * * Depth comes from layering, not from geometry. Several ribbons are summed at * different vertical offsets, speeds and thicknesses, and because each is * translucent the overlaps brighten where curtains cross. That is what real * aurora does, and it is why summing beats drawing one thick band. * * The noise is a stack of sines rather than a gradient-noise function. Aurora * is smooth and low-frequency, so nobody can tell — and it costs a handful of * instructions instead of the dozens simplex needs per sample, which matters * when the field is sampled once per ribbon per pixel. */ const VERTEX = `attribute vec2 position;void main() { gl_Position = vec4(position, 0.0, 1.0); }`; const FRAGMENT = `precision mediump float;uniform vec2 uResolution;uniform float uTime;uniform vec3 uColorA;uniform vec3 uColorB;uniform float uBands;uniform float uSpread;uniform float uGlow; /* Layered sines: smooth, cheap, and indistinguishable from noise at this frequency. */float wave(float x, float t, float seed) { return sin(x * 1.7 + t + seed) * 0.5 + sin(x * 3.1 - t * 0.7 + seed * 2.0) * 0.25 + sin(x * 5.3 + t * 0.4 + seed * 3.0) * 0.12;} void main() { vec2 uv = gl_FragCoord.xy / uResolution; vec3 colour = vec3(0.0); /* Fixed loop bound: GLSL ES 1.0 cannot loop to a uniform, so the count is compiled in and uBands fades the surplus out instead. */ for (int i = 0; i < 6; i++) { float fi = float(i); if (fi >= uBands) break; float seed = fi * 1.37; float centre = 0.5 + wave(uv.x, uTime * (0.18 + fi * 0.05), seed) * uSpread * 0.28; /* Thicker curtains sit further back, which reads as atmospheric depth. */ float thickness = 0.035 + fi * 0.02; float band = smoothstep(thickness, 0.0, abs(uv.y - centre)); /* Vertical falloff: aurora is bright at the base and dissolves upward. */ band *= smoothstep(1.0, 0.25, uv.y); vec3 tint = mix(uColorA, uColorB, fi / max(uBands - 1.0, 1.0)); colour += tint * band * uGlow; } /* Tone-mapped rather than clamped: overlapping curtains otherwise clip to flat white where they cross, losing exactly the detail layering created. */ colour = colour / (1.0 + colour); 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 AuroraProps { colorA?: string; colorB?: string; /** Curtains, up to 6 (the shader's compiled loop bound). */ bands?: number; speed?: number; /** Vertical travel of each curtain. */ spread?: number; /** Brightness before tone mapping. */ glow?: number; still?: boolean; className?: string;} export function Aurora({ colorA = "#c3f53c", colorB = "#2b8cff", bands = 5, speed = 1, spread = 1, glow = 1, still = false, className = "",}: AuroraProps) { 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"); gl.uniform3fv(uniform("uColorA"), toRgb(colorA)); gl.uniform3fv(uniform("uColorB"), toRgb(colorB)); gl.uniform1f(uniform("uBands"), Math.max(1, Math.min(6, bands))); gl.uniform1f(uniform("uSpread"), spread); gl.uniform1f(uniform("uGlow"), glow); const reduced = still || (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches); let uw = 0; let uh = 0; const resize = () => { const dpr = Math.min(devicePixelRatio || 1, 2); const w = Math.floor(canvas.clientWidth * dpr); const h = Math.floor(canvas.clientHeight * dpr); if (!w || !h || (w === uw && h === uh)) return; uw = w; uh = h; canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); gl.uniform2f(uResolution, w, h); }; const start = performance.now(); const draw = (now: number) => { resize(); gl.uniform1f(uTime, ((now - start) / 1000) * speed); gl.drawArrays(gl.TRIANGLES, 0, 3); }; let frame = 0; const loop = (now: number) => { draw(now); frame = requestAnimationFrame(loop); }; draw(reduced ? start + 4000 : start); if (!reduced) frame = requestAnimationFrame(loop); const observer = new ResizeObserver(() => { resize(); if (reduced) draw(start + 4000); }); observer.observe(canvas); return () => { cancelAnimationFrame(frame); observer.disconnect(); gl.deleteProgram(program); gl.deleteShader(vs); gl.deleteShader(fs); gl.deleteBuffer(buffer); }; }, [colorA, colorB, bands, speed, spread, glow, 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.2KB 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
Renders one composed frame mid-drift. A still aurora, not an empty panel.
- Accessibility
- Canvas is aria-hidden; decorative.
- No focusable elements.
- Bright bands over a dark ground: check text contrast against the brightest frame, not the first one.
- Watch out
- Six curtains means six field evaluations per pixel. Cheap, but it is still full-screen shading.
- Needs WebGL; renders nothing without it.