IrisBackgroundsCorona
Corona
The rim of a huge ring bowing up through the lower frame, molten orange-red dispersing to pale fringes and a blue sky, under heavy film grain.
Corona
An enormous ring centred well below the frame, so only its upper arc ever shows — bowing up through the lower half the way a solar corona or an annular eclipse photograph reads, never a clean vector loop. The band itself is wide and diffuse rather than a thin neon line: a pale yellow-green edge on its inner side, a wide molten orange-red body, a thin pale-cyan edge on its outer side, with a soft electric-blue atmosphere bleeding off that edge into a near-black sky at the corners, while the ring's own interior below the arc falls dark faster and tighter. Heavy, always-on film grain sits over the whole frame — the one texture every other field in the catalogue keeps to a near-invisible dither, brought forward here because it is what reads this as a photograph of light rather than a flat gradient.
The pointer does two things at once, the way a camera would answer a viewer leaning to one side: the whole arc parallaxes a little under it, and a hot glint rides the rim to whichever point sits nearest the cursor. With nobody pointing, the arc sits still and one faint comet drifts the rim on its own slow cycle instead.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { CoronaField } from "./CoronaField";
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">
<CoronaField />
</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 corona — the rim of an enormous ring, centred well below the
* frame so only its upper arc ever shows: bowing up through the lower half
* of the composition the way a solar corona or an annular eclipse photo
* reads, not a clean vector loop. The band itself is wide and diffuse
* rather than a thin neon line — a pale yellow-green edge on its inner
* (lower) side, a wide molten orange-red body, a thin pale-cyan edge on
* its outer (upper) side — with a soft electric-blue atmosphere bleeding
* upward off that outer edge and fading into a near-black ground at the
* corners, while the ring's own interior, below the arc, falls to near-
* black faster and tighter. Heavy, always-on film grain sits over the
* whole frame — the one texture every other field in this catalogue keeps
* to a near-invisible dither, brought forward here because it is what
* reads this as a photograph of light rather than a flat gradient.
*
* The pointer does two things at once, the way an actual camera would
* answer a viewer leaning to one side: the whole arc parallaxes a little
* under it, and a hot glint rides the rim to whichever point sits nearest
* the cursor, brightening as it settles there. With nobody pointing, the
* arc sits still and one faint comet drifts the rim on its own slow cycle
* instead.
*
* 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-coronafield__floor`
* underneath visible — a still arc in the same palette, never a blank box.
*
* Like `OrbitalArcField`, the geometry is a huge off-frame ellipse so only
* its rim ever crosses the visible box; unlike it, the palette is a fiery
* orange-red core rather than violet-blue, and the grain is left in at
* full strength rather than the near-invisible dither the rest of the
* catalogue uses.
*
* 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). `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_ground: [0.006, 0.008, 0.02], // near-black, cool navy
u_atmosphere: [0.05, 0.13, 0.42], // the huge, soft atmosphere beyond the rim
u_blue: [0.12, 0.34, 0.88], // brighter blue right outside the rim
u_fringeIn: [0.78, 0.9, 0.5], // pale yellow-green, the rim's inner edge
u_ringCore: [0.96, 0.32, 0.1], // the molten orange-red body of the rim
u_fringeOut: [0.78, 0.97, 0.95], // pale cyan-white, the rim's outer edge
};
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_ground;
uniform vec3 u_atmosphere;
uniform vec3 u_blue;
uniform vec3 u_fringeIn;
uniform vec3 u_ringCore;
uniform vec3 u_fringeOut;
uniform vec4 u_readA;
uniform float u_guard;
#define PI 3.14159265
/* Distance to an ellipse's rim, approximated by scaling into a circle of
radius ry first — exact ON the rim, positive outside it, negative
inside, smooth enough off it for a glow that never needs a true
Euclidean distance. */
float ellipseDist(vec2 p, float rx, float ry) {
vec2 s = vec2(p.x * (ry / rx), p.y);
return length(s) - ry;
}
float ellipseAngle(vec2 p, float rx, float ry) {
return atan(p.y / ry, p.x / rx);
}
void main() {
vec2 res = u_res / u_scale;
vec2 uv = gl_FragCoord.xy / u_scale / res; /* 0..1, y up */
vec2 sc = vec2(res.x / max(res.y, 1.0), 1.0);
vec2 q = uv * sc;
/* the huge ellipse: centred well below the frame, parallaxed a little
by the pointer, so only its upper rim ever crosses the visible box */
const float RX_A = 1.25;
const float RY_A = 1.35;
vec2 cA = vec2(0.5 * sc.x, -0.85);
cA.x += (u_pointer.x - 0.5) * sc.x * 0.05 * u_pointer.z;
cA.y += (u_pointer.y - 0.5) * 0.035 * u_pointer.z;
vec2 pA = q - cA;
float ang = ellipseAngle(pA, RX_A, RY_A);
/* a faint per-angle wobble, so the edge reads as drawn rather than a
perfect curve */
float wob = fbm(vec2(cos(ang), sin(ang)) * 2.4 + vec2(0.0, u_time * 0.015)) * 0.02;
float dA = ellipseDist(pA, RX_A, RY_A) - wob; /* 0 on the rim, + outside (up), - inside (down) */
/* the idle comet: a slow oscillation either side of the apex — the
nearest point of the ellipse to the viewer, which for a centre this
far below the frame sits at angle +PI/2, not -PI/2 */
float idlePhase = u_time * 0.05;
float idleTheta = PI * 0.5 + 1.1 * sin(idlePhase);
/* the pointer's glint: the rim angle nearest the cursor's own position
relative to the same ellipse centre */
vec2 ptr = (u_pointer.xy - 0.5) * sc;
float ptrTheta = ellipseAngle(ptr - cA, RX_A, RY_A);
float diffIdle = ang - idleTheta; diffIdle -= 2.0 * PI * floor((diffIdle + PI) / (2.0 * PI));
float diffPtr = ang - ptrTheta; diffPtr -= 2.0 * PI * floor((diffPtr + PI) / (2.0 * PI));
float comet = exp(-diffIdle * diffIdle / (0.5 * 0.5)) * 0.4
+ exp(-diffIdle * diffIdle / (0.07 * 0.07)) * 1.0;
float glint = exp(-diffPtr * diffPtr / (0.4 * 0.4)) * 0.35
+ exp(-diffPtr * diffPtr / (0.05 * 0.05)) * 1.1;
float hot = mix(comet, glint, u_pointer.z);
/* the rim is not a uniform-width tube: it fans wider toward the two
flanks, the way a suspended ring foreshortens as it turns away from
camera near the top corners. angOff is measured from the apex
(+PI/2), matching idleTheta's own centre above. */
float angOff = ang - PI * 0.5;
angOff -= 2.0 * PI * floor((angOff + PI) / (2.0 * PI));
float edge = smoothstep(0.15, 1.2, abs(angOff));
float fan = 1.0 + edge * 0.6;
/* the band itself: a tight, symmetric orange-red core right on the rim,
with a narrow pale yellow-green accent just inside it and a narrow
pale-cyan accent just outside it — three thin, well-separated peaks
rather than three equally-wide overlapping ones, so their colours
stay distinct instead of summing to white. */
float coreSigma = mix(0.05, 0.085, edge) * fan;
float ringBody = exp(-dA * dA / (coreSigma * coreSigma));
float fringeSigma = coreSigma * 0.55;
float dIn = dA + coreSigma * 1.35;
float dOut = dA - coreSigma * 1.35;
float fringeIn = exp(-dIn * dIn / (fringeSigma * fringeSigma));
float fringeOut = exp(-dOut * dOut / (fringeSigma * fringeSigma));
/* the huge, soft atmosphere beyond the rim's outer edge, fading into the
near-black ground toward the corners — a ramp that turns fully on
only past the fringe, multiplied by its own gentle far-side decay, so
it never bleeds back in under the rim the way a plain one-sided
gaussian would (clamping its distance to zero there instead of fading
to zero). */
float outerStart = coreSigma * 1.1;
float outerFull = coreSigma * 2.5;
float ramp = smoothstep(outerStart, outerFull, dA);
float atmoW = mix(0.32, 0.6, edge);
float atmoFar = exp(-pow(max(dA - outerFull, 0.0), 2.0) / (atmoW * atmoW));
float blueW = mix(0.16, 0.3, edge);
float blueFar = exp(-pow(max(dA - outerFull, 0.0), 2.0) / (blueW * blueW));
float atmo = ramp * atmoFar;
float blue = ramp * blueFar;
float grip = u_pointer.z;
vec3 col = u_ground;
col += u_atmosphere * atmo * 0.34;
col += u_blue * blue * 0.4;
col += u_fringeIn * fringeIn * (0.7 + hot * 0.25);
col += u_ringCore * ringBody * (0.82 + hot * 0.4 + grip * 0.12);
col += u_fringeOut * fringeOut * (0.6 + hot * 0.3 + grip * 0.1);
/* the hot point itself — a crisp, radial particle riding the rim,
wherever it currently sits, so a spot of the arc reads as something
actually travelling rather than merely brightened */
vec2 idlePos = cA + vec2(RX_A * cos(idleTheta), RY_A * sin(idleTheta));
vec2 ptrPos = cA + vec2(RX_A * cos(ptrTheta), RY_A * sin(ptrTheta));
vec2 hotPos = mix(idlePos, ptrPos, u_pointer.z);
vec2 dh = q - hotPos;
float hpHalo = exp(-dot(dh, dh) / 0.01);
float hpCore = exp(-dot(dh, dh) / 0.0016);
col += u_fringeOut * hpHalo * 0.22;
col += u_ringCore * hpCore * 0.45;
/* a faint, flat nebula filling the sky beyond the rim, for depth */
vec2 np = q * 0.6 + vec2(0.0, -u_time * 0.008);
float neb = fbm(np) * 0.5 + fbm(np * 2.1 + 4.0) * 0.5;
float nebDensity = smoothstep(0.1, 0.65, neb) * 0.04 * ramp;
col += u_atmosphere * nebDensity;
col *= mix(1.0, 0.4, smoothstep(0.3, 1.3, length(uv - vec2(0.5 * sc.x, 0.72))));
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 band = 1.0 - smoothstep(0.72, 2.1, m);
col = mix(col, holdUnder(col, 0.09), band * u_guard);
/* heavy, always-on film grain — the signature texture of a corona shot
at speed, kept strong across the whole frame (including the near-
black corners) rather than scaled away in the shadows, the way a real
high-ISO exposure carries visible noise everywhere. */
float g1 = vhash(gl_FragCoord.xy + vec2(u_time * 97.0, u_time * 61.0));
float g2 = vhash(gl_FragCoord.xy * 0.5 + vec2(u_time * -53.0, u_time * 41.0));
col += (g1 - 0.5) * 0.075 + (g2 - 0.5) * 0.045;
gl_FragColor = vec4(max(col, 0.0), 1.0);
}
`;
export interface CoronaFieldProps {
className?: string;
/**
* CSS selector for the block the reading guard should keep readable,
* resolved against `document`. `null` (the default) turns the guard off.
*/
guardSelector?: string | null;
}
export function CoronaField({
className,
guardSelector = null,
}: CoronaFieldProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const guardOn = guardSelector != null;
let guardEl: Element | null | undefined;
const readGuardEl = () => {
if (guardEl === undefined) {
guardEl = guardOn ? document.querySelector(guardSelector as string) : null;
}
return guardEl;
};
return mountShaderSurface(canvas, {
fragment: FRAG,
uniforms: [...Object.keys(PALETTE), "u_readA", "u_guard"],
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);
},
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 OrbitalArcField / HaloRingField: 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: 1_800_000,
dprCap: 1.5,
});
}, [guardSelector]);
return (
<div
className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
aria-hidden="true"
>
<div className="absolute inset-0 [background:radial-gradient(_circle_at_50%_148%,oklch(0.03_0.01_260)_0%,oklch(0.03_0.01_260)_27%,oklch(0.86_0.13_120)_32%,oklch(0.6_0.19_40)_39%,oklch(0.64_0.2_45)_47%,oklch(0.9_0.05_200)_51%,oklch(0.42_0.18_255_/_0.85)_58%,oklch(0.22_0.11_260_/_0.6)_72%,oklch(0.045_0.02_260)_90%_)]" />
<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>
);
}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
Reads and answers every request himself