← All particles

Aurora Veil

Curtains of light that fold, throwing bright vertical rays as they turn.

ambientcalmpremium144 particles · light · canvas-2d · automatic · looping
Variant

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.

255 lines · react only
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.

Night mode backdropWeather screenHero backgroundAmbient loading surface

Related effects