Aurora Veil
Curtains of light that fold, throwing bright vertical rays as they turn.
The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef } from "react";
/**
* Vibary · Aurora Veil
*
* Vertical curtains of light, folding and shifting.
*
* The technique: the bright rays are not drawn. Each curtain is a sheet
* sampled at evenly spaced points along its own length, and the sheet
* meanders in front of the viewer — so where it turns edge-on, many
* samples land on the same few pixels and their light adds up. Bright
* rays fall out of the sampling density on their own, exactly as they do
* in the sky, where a ray is simply a fold seen end-on.
*
* This is why an aurora painted as a blurred gradient never convinces:
* the rays are the subject, and they can only be a consequence of the
* fold. Nothing here measures compression or decides where a ray goes —
* every sample carries the same fixed light, and the folds do the rest.
*
* Self-contained: one canvas plus a column sprite per curtain.
* Works with zero props; tune via `colors`, `samples`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type AuroraVeilProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Curtain colors, one per curtain — this wants a dark sky behind it. */
colors?: string[];
/** Samples along each sheet. More is smoother, not brighter. */
samples?: number;
};
type VariantConfig = {
/** Fold amplitude as a fraction of the frame width. */
sway: number;
/** Fold travel in turns per second. */
speed: number;
/** Light carried by one sample. The folds multiply it, so keep it low. */
glow: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A faint veil that barely resolves into rays.
subtle: { sway: 0.1, speed: 0.035, glow: 0.05 },
// Folds clearly enough to throw rays, slow enough to ignore. Default.
default: { sway: 0.17, speed: 0.055, glow: 0.075 },
// Deeper folds moving faster; the rays sweep visibly across the frame.
playful: { sway: 0.26, speed: 0.085, glow: 0.1 },
};
type Curtain = {
color: string;
/** Where in its own fold cycle this sheet sits. */
phase: number;
/** Folds across the width of the frame. */
folds: number;
/** Fold travel relative to the variant's speed. */
drift: number;
/** Top of the curtain as a fraction of the height. */
top: number;
/** Curtain length as a fraction of the height. */
length: number;
/** Per-curtain weight, so the back ones stay behind. */
weight: number;
};
const DEFAULT_COLORS = ["#5FE0B4", "#59C9E0", "#7FA6F0"];
/** `#RRGGBB` plus an alpha, since gradient stops need rgba. */
function withAlpha(hex: string, alpha: number) {
const value = hex.replace("#", "");
const r = parseInt(value.slice(0, 2), 16);
const g = parseInt(value.slice(2, 4), 16);
const b = parseInt(value.slice(4, 6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
export default function AuroraVeil({
variant = "default",
colors = DEFAULT_COLORS,
samples = 48,
}: AuroraVeilProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const curtains: Curtain[] = [
{ color: colors[0], phase: 0.4, folds: 1.3, drift: 1, top: 0.02, length: 0.72, weight: 1 },
{
color: colors[Math.min(1, colors.length - 1)],
phase: 2.7,
folds: 1.8,
drift: -0.72,
top: 0.06,
length: 0.6,
weight: 0.8,
},
{
color: colors[Math.min(2, colors.length - 1)],
phase: 4.9,
folds: 0.9,
drift: 0.55,
top: 0,
length: 0.85,
weight: 0.55,
},
];
// One soft column per curtain colour, drawn once. The horizontal
// falloff has to be wider than the sample spacing or the sheet shows
// its own stitching where it stretches.
const SPRITE_WIDTH = 48;
const SPRITE_HEIGHT = 256;
const sprites = curtains.map((curtain) => {
const sprite = document.createElement("canvas");
sprite.width = SPRITE_WIDTH;
sprite.height = SPRITE_HEIGHT;
const spriteContext = sprite.getContext("2d");
if (!spriteContext) return sprite;
const across = spriteContext.createLinearGradient(0, 0, SPRITE_WIDTH, 0);
across.addColorStop(0, withAlpha(curtain.color, 0));
across.addColorStop(0.5, withAlpha(curtain.color, 1));
across.addColorStop(1, withAlpha(curtain.color, 0));
spriteContext.fillStyle = across;
spriteContext.fillRect(0, 0, SPRITE_WIDTH, SPRITE_HEIGHT);
// Envelope down the ray: nothing at the top, strongest a third of
// the way down, trailing away at the bottom. A ray with a hard end
// reads as a bar of colour.
const down = spriteContext.createLinearGradient(0, 0, 0, SPRITE_HEIGHT);
down.addColorStop(0, "rgba(0,0,0,0)");
down.addColorStop(0.34, "rgba(0,0,0,1)");
down.addColorStop(0.72, "rgba(0,0,0,0.55)");
down.addColorStop(1, "rgba(0,0,0,0)");
spriteContext.globalCompositeOperation = "destination-in";
spriteContext.fillStyle = down;
spriteContext.fillRect(0, 0, SPRITE_WIDTH, SPRITE_HEIGHT);
return sprite;
});
let width = 0;
let height = 0;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.floor(width * ratio));
canvas.height = Math.max(1, Math.floor(height * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
// Overlapping samples must add. This blending is internal to the
// canvas; the canvas still composites normally over the page.
context.globalCompositeOperation = "lighter";
};
resize();
const render = (time: number) => {
context.clearRect(0, 0, width, height);
const margin = width * 0.25;
const span = width + margin * 2;
// Column width tracks the spacing, so the sheet stays continuous
// whatever the frame size or sample count.
const columnWidth = (span / samples) * 3.6;
for (let index = 0; index < curtains.length; index++) {
const curtain = curtains[index];
const sprite = sprites[index];
context.globalAlpha = config.glow * curtain.weight;
for (let sample = 0; sample < samples; sample++) {
const s = sample / (samples - 1);
const phase = time * config.speed * curtain.drift * Math.PI * 2 + curtain.phase;
// Two folds out of step with each other. One alone gives a
// regular ripple; two give the sheet somewhere to double back.
const x =
-margin +
s * span +
Math.sin(s * curtain.folds * Math.PI * 2 + phase) * config.sway * width +
Math.sin(s * curtain.folds * 2.7 * Math.PI * 2 - phase * 0.7) * config.sway * width * 0.4;
// The bottom edge of the curtain frays as the fold travels.
const drop =
curtain.length *
(1 + Math.sin(s * 9.3 + phase * 1.4) * 0.13 + Math.sin(s * 21 - phase) * 0.06);
context.drawImage(
sprite,
x - columnWidth / 2,
height * curtain.top,
columnWidth,
height * drop
);
}
}
context.globalAlpha = 1;
};
// Reduced motion: the veil held at a phase where the folds have
// thrown rays. The shape of the light is the whole subject, and it
// does not need to move to be seen.
if (reduced) {
render(3.1);
const onResizeStill = () => {
resize();
render(3.1);
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let elapsed = 0;
let last = performance.now();
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
render(elapsed);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => resize();
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, colors, samples]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
Slow atmosphere for a dark surface — a weather screen, a night mode, the backdrop to a hero. The bright rays are never drawn. Each curtain is a sheet sampled at evenly spaced points along its own length, and the sheet meanders in front of the viewer, so where it turns edge-on many samples land on the same few pixels and their light adds up. The rays fall out of the sampling density on their own, which is exactly what a ray is in the sky: a fold seen end-on. That is why an aurora painted as a blurred gradient never convinces — the rays are the subject and they can only be a consequence of the fold. Nothing in the code measures compression or decides where a ray belongs.