Skip to content
3D

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.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Aurora } from "@/components/aurora"; <Aurora />

Customise

5

Capped at 6 — GLSL ES 1.0 cannot loop to a uniform bound.

1
1

Brightness before tone mapping.

1

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.