Skip to content

IrisBackgroundsAurora Veil

Aurora Veil

A curtain of luminous cyan reeds hanging from the top edge, surging under a dragged hand.

drape

Aurora Veil

Tall cyan spikes hang from the top edge over a near-black ground, their lengths wandering like a slow spectrum analyser, with aqua pooling along the side edges and a wash of green where the light meets the frame.

Drag across the field and the reeds under the cursor surge downward and flare, as if a hand were drawn down the fabric. The cyan/green set lives nowhere in the token ramp, so the CSS floor carries the colour.

  • cool
  • fabric
Family
drape
Status
Available
Licence
Free

Install

No installation needed — self-contained, paste-in code.

Usage

Drop it straight into a page.

example.tsx
import { AuroraVeil } from "./AuroraVeil";

export default function Example() {
  return (
    // Fills its nearest positioned ancestor (it renders itself `absolute
    // inset-0`) — give it a sized, relatively positioned box.
    <div className="relative isolate h-[32rem] w-full overflow-hidden rounded-2xl">
      <AuroraVeil />
    </div>
  );
}

Component

The real source, exactly as it ships — multiple files, kept together.

"use client";

import { useEffect, useRef } from "react";
import { mountShaderSurface } from "@/lib/shader-surface";

/**
 * A live WebGL curtain of luminous reeds — tall cyan spikes hanging from the
 * top edge over a near-black ground, their lengths wandering like a slow
 * spectrum analyser, with aqua pooling along the side edges and a wash of
 * green where the light meets the frame. The pointer is real: drag across
 * the field and the reeds under the cursor surge downward and flare, as if a
 * hand were drawn down the fabric.
 *
 * Drop it into any `position: relative`/`isolate` parent — it fills the box.
 * Built on `lib/shader-surface.ts`, so every degradation path is already
 * handled: no WebGL, a blocked or lost context, a hidden tab, or
 * `prefers-reduced-motion` all leave the CSS `.iris-veilfield__floor`
 * underneath visible (a still composed frame in the same palette, never a
 * blank box).
 *
 * Like `TileField` (and unlike `SilkField`), the palette is a fixed
 * cyan/green set passed as uniforms rather than read from the token ramp —
 * these hues live nowhere in the token set, so the CSS floor is the
 * degradation fallback instead of a live token read.
 *
 * Reading guard: when `guardSelector` resolves to an element, the field
 * measures that block every frame and clamps its own luminance under a
 * ceiling inside that region (hue and saturation untouched), so copy laid
 * over the field holds WCAG AA with no overlay layer. `null` (the default)
 * turns the guard off — for decorative use where nothing sits on top.
 */

/* Palette, sRGB 0–1. Uniforms, not tokens — see the note above. */
const PALETTE: Record<string, [number, number, number]> = {
  u_deep: [0.019, 0.031, 0.048],
  u_aqua: [0.243, 0.855, 0.933],
  u_pale: [0.741, 0.933, 0.98],
  u_moss: [0.404, 0.851, 0.541],
};

const FRAG = `
uniform vec2  u_res;
uniform float u_time;
uniform float u_scale;
uniform vec3  u_pointer;   /* x, y (0 bottom .. 1 top), presence 0..1 */

uniform vec3 u_deep;   /* the near-black ground behind the veil */
uniform vec3 u_aqua;   /* the cyan the reeds are lit in */
uniform vec3 u_pale;   /* pale blue-white, the hot core toward the top */
uniform vec3 u_moss;   /* the green that pools along the side edges */

/* Reading region for the contrast guard: xy = centre (uv, y up),
   zw = half-extent. Measured from the live copy block every frame.
   u_guard is 0 when the guard is off. */
uniform vec4  u_readA;
uniform float u_guard;

/* 1 for the default cyan/green pooling at the left and right edges, 0 to
   keep the ground flat there — for placements that already sit on their
   own edge treatment and don't need the veil's. */
uniform float u_edgeGlow;

/* How many luminous reeds span the width. One line to retune density. */
const float REEDS = 40.0;

void main() {
  vec2 res = u_res / u_scale;
  vec2 uv  = gl_FragCoord.xy / u_scale / res;          /* 0..1, y up */
  float aspect = res.x / res.y;

  /* ---- per-reed frame --------------------------------------------- */
  float slot   = uv.x * REEDS;
  float cid    = floor(slot);
  float within = fract(slot) - 0.5;                    /* -0.5..0.5 across one reed */

  /* the whole curtain leans and breathes, slowly */
  float sway = fbm(vec2(cid * 0.16, u_time * 0.045));
  within += sway * 0.16;

  float r1 = dotHash(vec2(cid, 3.0));
  float r2 = dotHash(vec2(cid, 17.0));

  /* the pointer works on the column, not a point: reeds under the cursor
     surge downward and flare, like a hand drawn down the fabric */
  float colDist = abs(uv.x - u_pointer.x);
  float near = u_pointer.z * exp(-colDist * colDist * 30.0);

  /* spike tip: hangs from the top edge, its length wandering like a slow
     spectrum analyser; the cursor pulls the nearest tips further down */
  float beat = fbm(vec2(cid * 0.55, u_time * 0.24 + r1 * 12.0));
  float tipY = 0.36 + 0.50 * (0.5 + 0.5 * beat);
  tipY -= near * 0.40;

  /* bright from the top edge down to the tip, pointed at the tip: the reed
     narrows as it descends, so the bottom is a fine luminous needle and the
     gaps between reeds stay black */
  float taper = smoothstep(1.02, tipY - 0.10, uv.y);   /* 1 at top .. 0 at tip */
  float halfW = mix(0.055, 0.20, taper) * (0.8 + 0.5 * r2 + near * 0.7);
  float reed  = smoothstep(halfW, halfW * 0.18, abs(within));
  float body  = smoothstep(tipY - 0.02, tipY + 0.18, uv.y);
  float spike = reed * body;

  /* a faint full-height thread so the lower field keeps its vertical grain */
  float thread = smoothstep(0.055, 0.010, abs(within))
               * (0.04 + 0.09 * r2)
               * smoothstep(-0.05, 0.5, uv.y);

  /* ---- ground + edges -------------------------------------------- */
  float edgeX    = abs(uv.x - 0.5) * 2.0;              /* 0 centre .. 1 edge */
  float edgeGlow = smoothstep(0.48, 1.0, edgeX);
  float midBand  = smoothstep(0.05, 0.42, uv.y) * smoothstep(0.95, 0.45, uv.y);

  vec3 col = u_deep;
  col = mix(col, u_aqua * 0.5, edgeGlow * 0.72 * u_edgeGlow);
  col = mix(col, u_moss,       edgeGlow * midBand * 0.6 * u_edgeGlow);
  col = mix(col, u_pale,       smoothstep(0.84, 1.0, edgeX) * 0.62 * u_edgeGlow);

  /* ---- the veil ------------------------------------------------- */
  vec3 spikeCol = mix(u_aqua, u_pale, smoothstep(0.70, 1.04, uv.y) * 0.55);
  col += spikeCol * spike * (0.85 + 0.45 * r1);
  col += u_aqua * thread;

  /* pointer bloom: the touched reeds go hot, plus a soft aqua wash */
  col += u_pale * spike * near * 1.15;
  vec2 P   = (uv - 0.5) * vec2(aspect, 1.0);
  vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  col += u_aqua * u_pointer.z * exp(-dot(P - ptr, P - ptr) * 16.0) * 0.24;

  /* vertical vignette — the field is brightest through the reeds and settles
     toward a deep base, but never all the way to black, so it still reads at
     a short landscape crop (the marquee card) as well as a tall one */
  col *= 0.52 + 0.44 * smoothstep(0.04, 0.94, uv.y) + 0.05 * smoothstep(0.2, 0.0, uv.y);

  col = max(col, 0.0);

  /* ---- the reading guard (see TileField for the full rationale) - */
  vec2 rd = abs(uv - u_readA.xy) / max(u_readA.zw, vec2(0.02));
  float m = mix(max(rd.x, rd.y), length(rd), 0.4);
  float guardBand = 1.0 - smoothstep(0.72, 2.1, m);
  col = mix(col, holdUnder(col, 0.09), guardBand * u_guard);

  col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.2 / 255.0);

  gl_FragColor = vec4(col, 1.0);
}
`;

export interface AuroraVeilProps {
  className?: string;
  /**
   * CSS selector for the block the reading guard should keep readable,
   * resolved within the nearest `.iris-hero` (falling back to the
   * document). `null` (the default) turns the guard off.
   */
  guardSelector?: string | null;
  /**
   * The cyan/green pooling along the left and right edges. On by default;
   * set `false` where the field already sits inside another edge treatment
   * and the veil's own pooling would just double up on it.
   */
  edgeGlow?: boolean;
}

export function AuroraVeil({
  className,
  guardSelector = null,
  edgeGlow = true,
}: AuroraVeilProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const guardOn = guardSelector != null;

    /* The block the reading guard protects. Looked up once, lazily. */
    let guardEl: Element | null | undefined;
    const readGuardEl = () => {
      if (guardEl === undefined) {
        guardEl = guardOn
          ? (canvas.closest(".iris-hero") ?? document).querySelector(
              guardSelector as string
            )
          : null;
      }
      return guardEl;
    };

    return mountShaderSurface(canvas, {
      fragment: FRAG,
      uniforms: [...Object.keys(PALETTE), "u_readA", "u_guard", "u_edgeGlow"],
      onInit: (gl, u) => {
        for (const name of Object.keys(PALETTE)) {
          if (u[name]) gl.uniform3fv(u[name], PALETTE[name]);
        }
        if (u.u_readA) gl.uniform4f(u.u_readA, 0.5, 0.5, 0.44, 0.32);
        if (u.u_guard) gl.uniform1f(u.u_guard, guardOn ? 1 : 0);
        if (u.u_edgeGlow) gl.uniform1f(u.u_edgeGlow, edgeGlow ? 1 : 0);
      },
      onFrame: (gl, u, s) => {
        if (!guardOn || !u.u_readA) return;
        let cx = 0.5, cy = 0.5, hw = 0.44, hh = 0.32;
        const el = readGuardEl();
        const { rect } = s;
        if (el && rect.width > 0 && rect.height > 0) {
          const r = el.getBoundingClientRect();
          const padX = rect.width * 0.09;
          const padY = rect.height * 0.11;
          cx = (r.left + r.width / 2 - rect.left) / rect.width;
          cy = 1 - (r.top + r.height / 2 - rect.top) / rect.height;
          hw = (r.width / 2 + padX) / rect.width;
          hh = (r.height / 2 + padY) / rect.height;
        }
        gl.uniform4f(u.u_readA, cx, cy, hw, hh);
      },
      onPainted: () => canvas.setAttribute("data-shader", "on"),
      /* No onIdle — same contract as SilkField: once painted, the last frame
         stays on screen while the surface is parked off-view, so scrolling
         away and back does not crossfade to the static floor. A lost context
         still clears it below. */
      onLost: () => canvas.removeAttribute("data-shader"),
      maxPixels: 2_200_000,
      dprCap: 1.5,
    });
  }, [guardSelector, edgeGlow]);

  return (
    <div
      className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
      aria-hidden="true"
    >
      <div className="absolute inset-0 [background:radial-gradient(_62%_96%_at_0%_50%,oklch(0.9_0.13_202)_0%,transparent_60%_),radial-gradient(_62%_96%_at_100%_50%,oklch(0.9_0.13_202)_0%,transparent_60%_),radial-gradient(_38%_48%_at_0%_58%,oklch(0.84_0.17_152)_0%,transparent_72%_),radial-gradient(_38%_48%_at_100%_58%,oklch(0.84_0.17_152)_0%,transparent_72%_),radial-gradient(_96%_78%_at_50%_40%,oklch(0.11_0.03_235)_0%,transparent_76%_),oklch(0.12_0.03_235)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-linear-gradient(_90deg,transparent_0_17px,oklch(0.86_0.13_202_/_0.26)_17px_19px_)] after:[-webkit-mask-image:linear-gradient(_180deg,#000_0_6%,oklch(0_0_0_/_0.3)_52%,transparent_90%_)] after:[mask-image:linear-gradient(_180deg,#000_0_6%,oklch(0_0_0_/_0.3)_52%,transparent_90%_)] [.iris-cta\_\_bg_&]:[background:radial-gradient(96%_78%_at_50%_40%,oklch(0.11_0.03_235)_0%,transparent_76%),oklch(0.12_0.03_235)]" />
      <canvas ref={canvasRef} className="absolute inset-0 w-full h-full opacity-0 transition-opacity duration-[--duration-slow] ease-[--ease-standard] data-[shader=on]:opacity-100" />
    </div>
  );
}

More backgrounds

Custom work

Need one that isn't in the catalogue?

Describe what you're after and I'll reply by email — no promise on turnaround yet, this is a new channel, not a service with a set price or queue.

Fardin Omor Afnan

Fardin Omor Afnan

Reads and answers every request himself

Or start with a section.

Whole page sections, composed and ready to drop in — take one and build the rest of the page around it.

Browse sections