Aurora Curtain
Rays that arrive where a sampled sheet folds end-on, so no line of code decides where a ray belongs.
The scene in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
"use client";
import * as React from "react";
import * as THREE from "three";
/**
* Aurora Curtain — the rays are never drawn.
*
* The bright vertical rays are the subject of an aurora, and they are
* not a thing hanging in the sky: they are a fold seen end-on. Walk
* along a sheet of light and where it turns edge-on to you, a long
* stretch of it collapses into a single column, and all of that light
* arrives in the same few pixels.
*
* Which is why the sheet here is parameterised along the horizon and
* not up the frame. That is the whole effect. A curtain whose offset
* varies with height can only ever wobble; one whose offset varies
* along its length can fold. Each pixel samples the sheet at 32 points
* and adds up whatever lands on its column — where the sheet folds,
* consecutive samples land on top of one another and the ray appears
* out of the arithmetic. Nothing measures compression, and no line of
* code decides where a ray belongs.
*
* Single file, react and three only. No textures, no add-ons, no
* environment map: everything is arithmetic per pixel.
*/
type Variant = "subtle" | "default" | "playful";
const VARIANTS = {
// flowRate is a ratio (how fast the fold meanders), foldCount is how
// many curtains hang, rayOpacity is how hard the light is pushed, and
// sweepScale is how far up the frame the curtain reaches.
subtle: { flowRate: 0.055, foldCount: 3, rayOpacity: 0.34, sweepScale: 0.9 },
default: { flowRate: 0.12, foldCount: 5, rayOpacity: 0.52, sweepScale: 1.25 },
playful: { flowRate: 0.26, foldCount: 8, rayOpacity: 0.74, sweepScale: 1.7 },
} as const;
/**
* Two palettes rather than one adaptive palette.
*
* Most components in this library mix their neutrals out of the
* inherited text colour. A full-bleed effect cannot: it paints every
* pixel, so it *is* the background and has to choose one. It reads the
* inherited colour only to decide which way round the page is, then
* commits — light piled onto a dark sky, or ink laid onto a pale one.
* The mechanism is identical either way; only the contrast reverses.
*/
const PALETTES = {
night: {
// Written straight to gl_FragColor, so these are sRGB and not
// linear — and they are the poster gradient's own stops, so the
// still a queued card shows is the same sky the shader paints.
skyTop: [0.075, 0.102, 0.18],
skyFoot: [0.024, 0.039, 0.078],
low: [0.23, 0.85, 0.63],
high: [0.52, 0.40, 0.88],
css: "linear-gradient(180deg, #131a2e 0%, #09101e 55%, #060a14 100%)",
},
day: {
skyTop: [0.933, 0.945, 0.965],
skyFoot: [0.969, 0.973, 0.98],
low: [0.11, 0.42, 0.35],
high: [0.32, 0.18, 0.47],
css: "linear-gradient(180deg, #eef1f6 0%, #f4f6f9 55%, #f7f8fa 100%)",
},
} as const;
const VERTEX = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
// PlaneGeometry(2, 2) already spans clip space, so no matrices.
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
const FRAGMENT = /* glsl */ `
varying vec2 vUv;
uniform vec2 uResolution;
uniform float uTime;
uniform float uFolds;
uniform float uOpacity;
uniform float uSweep;
uniform float uInk;
uniform vec3 uSkyTop;
uniform vec3 uSkyFoot;
uniform vec3 uLow;
uniform vec3 uHigh;
// Samples taken along the curtain per pixel. The spacing between
// them has to stay under SIGMA or the sheet reads as a row of dots
// instead of a surface: 0.40 / 32 = 0.0125 against a sigma of 0.020.
const int TAPS = 32;
const float WINDOW = 0.40;
const float SIGMA = 0.020;
/**
* Where the curtain stands, as a function of distance along it.
*
* This is the whole effect. The sheet is parameterised along the
* horizon, not up the frame — that is the correction that makes rays
* possible at all, because a fold is the sheet turning end-on as you
* walk along it, and a curtain whose offset varies with height can
* only ever wobble. Where dX/ds passes through zero, a stretch of
* sheet collapses into one column and every sample in that stretch
* lands on the same few pixels.
*/
float sheetX(float s, float t, float folds) {
return s
+ 0.100 * sin((6.0 + folds * 2.4) * s + 0.53 * t)
+ 0.045 * sin((3.1 + folds * 0.9) * s - 0.31 * t + 1.7);
}
/**
* How tall the curtain stands at that point along it.
*
* The fast term matters more than it looks. With only the slow one,
* the height is near enough constant across the width of a single ray
* and every ray ends in the same flat line — which reads as a row of
* bars rather than as a torn sheet.
*/
float sheetTop(float s, float t) {
return 0.62 + 0.16 * sin(1.9 * s + 0.23 * t) + 0.08 * sin(7.3 * s - 0.40 * t);
}
float hash(vec2 p) {
return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5453);
}
void main() {
float aspect = uResolution.x / max(uResolution.y, 1.0);
float x = vUv.x * aspect;
float y = vUv.y;
float glow = 0.0;
float high = 0.0;
for (int j = 0; j < TAPS; j++) {
float s = x + ((float(j) + 0.5) / float(TAPS) - 0.5) * WINDOW;
float dx = x - sheetX(s, uTime, uFolds);
float across = exp(-(dx * dx) / (2.0 * SIGMA * SIGMA));
float top = sheetTop(s, uTime) * uSweep;
// Sharp along the bottom edge, long fade to the top: that
// asymmetry is most of what tells a viewer which way up this is.
float foot = smoothstep(0.0, 0.06, y);
float head = (1.0 - smoothstep(top * 0.15, top, y)) * exp(-y * 0.75);
// Not 'sample' — reserved in GLSL, like 'active' and 'filter'.
float weight = across * foot * head;
glow += weight;
high += weight * smoothstep(top * 0.20, top * 0.85, y);
}
// Normalised so an unfolded stretch of sheet comes out at about 1.
// Nothing here looks for a fold: the pile-up simply arrives.
float density = glow / 4.0;
// Expand, then roll off. Clamping instead would flatten every ray
// into the same white slab, which is what a blurred gradient looks
// like and the reason those never convince.
float ray = pow(density, 2.2);
float lum = ray / (1.0 + ray * 0.35);
float fringe = clamp(high / max(glow, 0.0001), 0.0, 1.0);
vec3 curtain = mix(uLow, uHigh, fringe * fringe);
vec3 sky = mix(uSkyFoot, uSkyTop, smoothstep(0.0, 1.0, y));
float amount = lum * uOpacity * 0.42;
vec3 lit = sky + curtain * amount;
vec3 inked = mix(sky, curtain, clamp(amount * 1.4, 0.0, 1.0));
vec3 color = mix(lit, inked, uInk);
// A gradient this smooth bands on an 8-bit display, and the banding
// is the first thing that makes it look cheap.
color += (hash(gl_FragCoord.xy) - 0.5) / 255.0;
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
`;
/** Relative luminance of a computed `rgb()` colour, 0–1. */
function luminanceOf(color: string): number {
const parts = color.match(/[\d.]+/g);
if (!parts || parts.length < 3) return 0.9;
const [r, g, b] = parts.slice(0, 3).map((value) => Number(value) / 255);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export type AuroraCurtainProps = {
variant?: Variant;
/** Any CSS height. The effect fills whatever box it is given. */
height?: string;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
};
export function AuroraCurtain({
variant = "default",
height = "100%",
className,
style,
children,
}: AuroraCurtainProps) {
const hostRef = React.useRef<HTMLDivElement | null>(null);
const cfg = VARIANTS[variant] ?? VARIANTS.default;
React.useEffect(() => {
const host = hostRef.current;
if (!host) return;
// Which way round is the page? Light text means a dark page.
const palette =
luminanceOf(getComputedStyle(host).color) > 0.5 ? PALETTES.night : PALETTES.day;
host.style.background = palette.css;
const reduced =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: false,
alpha: false,
powerPreference: "low-power",
});
} catch {
// No WebGL. The CSS sky above is already painted and is a fair
// still of the effect, so there is nothing further to do.
return;
}
const canvas = renderer.domElement;
canvas.style.cssText =
"position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;";
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
// First child, not last: an absolutely positioned canvas appended
// after the content would paint over it, and a backdrop that hides
// the headline is not a backdrop.
host.insertBefore(canvas, host.firstChild);
const uniforms = {
uResolution: { value: new THREE.Vector2(1, 1) },
uTime: { value: 0 },
uFolds: { value: 5 },
uOpacity: { value: 0.52 },
uSweep: { value: 1.25 },
uInk: { value: palette === PALETTES.day ? 1 : 0 },
uSkyTop: { value: new THREE.Vector3(...palette.skyTop) },
uSkyFoot: { value: new THREE.Vector3(...palette.skyFoot) },
uLow: { value: new THREE.Vector3(...palette.low) },
uHigh: { value: new THREE.Vector3(...palette.high) },
};
uniforms.uFolds.value = cfg.foldCount;
uniforms.uOpacity.value = cfg.rayOpacity;
uniforms.uSweep.value = cfg.sweepScale;
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader: VERTEX,
fragmentShader: FRAGMENT,
uniforms,
depthTest: false,
depthWrite: false,
});
const scene = new THREE.Scene();
scene.add(new THREE.Mesh(geometry, material));
const camera = new THREE.Camera();
const resize = () => {
const width = Math.max(1, host.clientWidth);
const heightPx = Math.max(1, host.clientHeight);
renderer.setSize(width, heightPx, false);
uniforms.uResolution.value.set(width, heightPx);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(host);
let frame = 0;
let phase = 0;
let last = 0;
let running = false;
const draw = () => renderer.render(scene, camera);
const tick = (now: number) => {
const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
phase += delta * cfg.flowRate * 6.0;
uniforms.uTime.value = phase;
draw();
frame = requestAnimationFrame(tick);
};
const start = () => {
if (running) return;
running = true;
last = 0;
frame = requestAnimationFrame(tick);
};
const stop = () => {
running = false;
cancelAnimationFrame(frame);
};
// A lost context is routine, not exceptional: a browser grants a
// limited number and drops the oldest when a page asks for more.
// preventDefault is what makes the restore possible at all.
const onLost = (event: Event) => {
event.preventDefault();
stop();
};
const onRestored = () => {
resize();
start();
};
canvas.addEventListener("webglcontextlost", onLost);
canvas.addEventListener("webglcontextrestored", onRestored);
if (reduced) {
// One frame, held. The fold is still a fold and the rays are
// still where the slope vanishes — the sky simply stops moving.
uniforms.uTime.value = 1.6;
draw();
} else {
start();
}
return () => {
stop();
observer.disconnect();
canvas.removeEventListener("webglcontextlost", onLost);
canvas.removeEventListener("webglcontextrestored", onRestored);
geometry.dispose();
material.dispose();
renderer.dispose();
// dispose() releases three's own objects; the context itself is
// only handed back here, and this section's whole budget depends
// on it being handed back promptly.
renderer.forceContextLoss();
canvas.remove();
};
}, [cfg]);
return (
<div
ref={hostRef}
className={className}
style={{
position: "relative",
width: "100%",
height,
overflow: "hidden",
background: PALETTES.night.css,
...style,
}}
>
{children}
</div>
);
}
export default AuroraCurtain;About this effect
A hero backdrop for a dark screen — a night mode, a weather view, the top of a landing page. The vertical rays are the whole subject of an aurora and not one of them is drawn. A sheet of light hangs over the horizon and meanders along its own length; where it turns edge-on to the viewer, a long stretch of it collapses into one column and all that light arrives in the same few pixels. Parameterising the sheet along the horizon rather than up the frame is the entire effect, and it is the part that is easy to get backwards: a curtain whose offset varies with height can only wobble, never fold, and wobble is exactly why a painted aurora reads as a gradient. Each pixel samples the sheet at 32 points and sums what lands on its column, so the pile-up — and therefore the ray — falls out of the arithmetic rather than being placed. It paints every pixel, so unlike most of this library it cannot mix its neutrals from the host: it reads the inherited text colour to decide which way round the page is, then commits — light piled onto a dark sky, or ink laid onto a pale one.
Related effects
- Fog BankSlabs composited back to front, each dimming what is behind it, so the bank has an inside rather than a surface.
- Plasma BloomThree fields multiplied rather than added, so light appears only where all three agree and most of the frame stays dark.
- Silk SheenBrightness taken from thread direction rather than position, so the highlight moves because the cloth turned.