IrisBackgroundsEvent Horizon
Event Horizon
A flat black event horizon ringed by a lensed halo, a tilted disk of orbiting plasma, two flickering polar jets, and a gravitational-lensing grid drifting slowly in — no pointer interaction, the animation runs entirely on its own.
Event Horizon
A tight photon ring and a wider lensed halo sit right against the horizon's own edge — the far side of the accretion disk bent up and over the black silhouette, the way a real one photographs. The disk itself rides tilted like a saucer around the hole, swirling bands of blue-white plasma cooling from a hot inner edge to a dimmer rim, brighter on the side sweeping toward the viewer. Two soft jets flicker outward from the poles, perpendicular to the disk. A wireframe of concentric rings and sparse spokes drifts slowly inward across the whole field, denser and brighter the closer it gets to the hole — the grid gravity itself is bending. A faint starfield twinkles in the space around it, and a pool of blue nebula gas gathers low in the frame.
Unlike the rest of the catalogue, nothing here answers to the cursor: the disk's orbit, the jets' flicker, the grid's drift and the stars' twinkle are all self-sustaining — pure graphical animation, the way the object itself would keep turning whether or not anyone was looking.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { EventHorizonField } from "./EventHorizonField";
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">
<EventHorizonField />
</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 black hole — a flat black event horizon ringed by a tight
* photon ring and a wider lensed halo (the far side of the accretion disk
* bent up and over the silhouette, the way a real horizon photographs), a
* tilted disk of swirling blue-white plasma orbiting it, brighter on the
* side sweeping toward the viewer, two soft polar jets flickering outward
* along its axis, and a slow gravitational-lensing wireframe of rings and
* spokes drifting inward across the whole field. A faint starfield twinkles
* in the space around it, and a pool of nebula gas gathers low in the frame.
*
* Every motion here is autonomous — the disk's own orbit, the jets' own
* flicker, the grid's own inward drift, the stars' own twinkle — there is
* no pointer interaction: `u_pointer` isn't even read by this shader,
* unlike the rest of the catalogue's cursor-driven fields.
*
* One of the reusable background fields (`VortexField`, `HaloRingField`,
* …). 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-eventhorizonfield__floor`
* underneath visible — a still frame in the same palette, never a blank box.
*
* Like `VortexField` / `HaloRingField`, the palette is a fixed cool-blue set
* passed as uniforms rather than read from the accent ramp — deep space has
* no amber in it.
*
* 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.018], // near-black, cool navy space
u_void: [0.0, 0.0, 0.0], // the flat black event horizon
u_ring: [0.82, 0.92, 1.0], // the tight photon ring and lensed halo
u_diskHot: [0.78, 0.9, 1.0], // the disk's hot inner edge
u_disk: [0.14, 0.4, 0.88], // the disk's cooler outer reach
u_grid: [0.22, 0.5, 0.92], // the lensing wireframe, rings and spokes
u_jet: [0.55, 0.76, 1.0], // the two polar jets
u_star: [0.9, 0.95, 1.0], // the background starfield
u_nebula: [0.04, 0.1, 0.32], // the gas pooling low in the frame
};
const FRAG = `
uniform vec2 u_res;
uniform float u_time;
uniform float u_scale;
uniform vec3 u_ground;
uniform vec3 u_void;
uniform vec3 u_ring;
uniform vec3 u_diskHot;
uniform vec3 u_disk;
uniform vec3 u_grid;
uniform vec3 u_jet;
uniform vec3 u_star;
uniform vec3 u_nebula;
uniform vec4 u_readA;
uniform float u_guard;
/* thin periodic line at every integer of x, width w */
float hline(float x, float w) {
float f = fract(x);
float d = min(f, 1.0 - f);
return 1.0 - smoothstep(0.0, w, d);
}
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;
vec2 sc = vec2(aspect, 1.0);
vec2 q = uv * sc;
vec2 centre = vec2(0.5 * aspect, 0.52);
vec2 p = q - centre;
float r = max(length(p), 1e-4);
float ang = atan(p.y, p.x);
vec3 col = u_ground;
/* a faint starfield, twinkling on its own, held back near the hole */
vec2 sp = uv * vec2(150.0, 95.0);
vec2 sid = floor(sp);
vec2 sf = fract(sp);
float sh = dotHash(sid);
if (sh > 0.965) {
vec2 jitter = vec2(dotHash(sid + 1.7), dotHash(sid + 5.3));
float d = length(sf - jitter);
float twinkle = max(0.4 + 0.6 * sin(u_time * (1.3 + sh * 2.0) + sh * 40.0), 0.0);
float star = exp(-d * d * 700.0) * twinkle;
float away = smoothstep(0.22, 0.6, r);
col += u_star * star * away * (sh - 0.965) * 28.0;
}
/* a pool of nebula gas gathering low in the frame */
float nebNoise = fbm(vec2(q.x * 1.3, q.y * 1.3 - u_time * 0.025)) * 0.5 + 0.5;
float nebMask = smoothstep(0.62, -0.05, uv.y);
col += u_nebula * nebMask * (0.35 + 0.65 * nebNoise) * 0.55;
/* the accretion disk: a tilted ellipse of swirling plasma orbiting the
horizon, hot near the inner edge and cooling outward, brighter on the
side sweeping toward the viewer (relativistic beaming) */
float tilt = 0.38;
vec2 pd = vec2(p.x, p.y / tilt);
float rd = max(length(pd), 1e-4);
float adisk = atan(pd.y, pd.x);
float horizonR = 0.135;
float diskInner = horizonR * 1.3;
float diskOuter = horizonR * 3.5;
float diskMask = smoothstep(diskInner - 0.015, diskInner + 0.02, rd)
* (1.0 - smoothstep(diskOuter - 0.15, diskOuter, rd));
float swirl = fbm(vec2(rd * 3.4, adisk * 1.4 - u_time * 0.06));
float bands = 0.5 + 0.5 * sin(adisk * 2.0 + rd * 15.0 - u_time * 0.5 + swirl * 1.4);
float beam = mix(0.4, 1.25, smoothstep(-1.0, 1.0, cos(adisk)));
float heat = clamp((rd - diskInner) / max(diskOuter - diskInner, 1e-4), 0.0, 1.0);
vec3 diskCol = mix(u_diskHot, u_disk, heat);
col += diskCol * diskMask * (0.45 + 0.65 * bands) * beam * 1.15;
/* two soft polar jets, perpendicular to the disk, flickering outward */
float jetHalf = 0.018 + abs(p.y) * 0.22;
float jetBody = exp(-(p.x * p.x) / (jetHalf * jetHalf));
float jetFall = exp(-abs(p.y) * 1.25);
float jetFlicker = 0.55 + 0.45 * fbm(vec2(p.y * 3.2 - u_time * 1.1, ang));
float jetGate = smoothstep(horizonR * 0.75, horizonR * 1.1, abs(p.y));
col += u_jet * jetBody * jetFall * jetFlicker * jetGate * 0.85;
/* the gravitational-lensing wireframe: concentric rings, denser toward
the horizon, drifting slowly inward, plus a sparse set of spokes */
float gv = 5.2 / (r + 0.22) - u_time * 0.05;
float ringsLine = hline(gv, 0.3);
col += u_grid * ringsLine * exp(-r * 1.3) * 0.45;
float spokes = hline(ang * 5.0 / 6.2831853 + u_time * 0.012, 0.05);
float spokeGate = smoothstep(horizonR * 1.25, horizonR * 1.8, r);
col += u_grid * spokes * exp(-r * 1.9) * spokeGate * 0.22;
/* the lensed halo: light from the far side of the disk, bent up and over
the horizon's own silhouette the way a real one photographs */
float haloR = horizonR * 1.2;
float dHalo = r - haloR;
float halo = exp(-dHalo * dHalo * 1100.0);
col += mix(u_ring, u_diskHot, 0.3) * halo * 0.9;
/* the photon ring, tight against the horizon's own edge */
float ringR = horizonR * 1.04;
float dRing = r - ringR;
float photonRing = exp(-dRing * dRing * 2600.0);
col += u_ring * photonRing * 1.6;
col += u_ring * exp(-r * r * 22.0) * 0.18;
/* the event horizon itself: flat black, occluding everything behind it */
float horizon = 1.0 - smoothstep(horizonR - 0.006, horizonR + 0.006, r);
col = mix(col, u_void, horizon);
/* corner vignette */
vec2 corner = uv - 0.5;
float cvig = length(corner * vec2(1.0, 1.2));
col *= mix(1.0, 0.6, smoothstep(0.55, 1.05, cvig));
col = max(col, 0.0);
/* ---- the reading guard (see TileField for the full rationale) ---- */
vec2 rg = abs(uv - u_readA.xy) / max(u_readA.zw, vec2(0.02));
float md = mix(max(rg.x, rg.y), length(rg), 0.4);
float guardBand = 1.0 - smoothstep(0.72, 2.1, md);
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 EventHorizonFieldProps {
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 EventHorizonField({ className, guardSelector = null }: EventHorizonFieldProps) {
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 VortexField / HaloRingField: once
painted, the last frame stays on screen while the surface is parked
off-view. onLost drops back to the CSS floor. */
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(_7%_9%_at_50%_48%,oklch(0.02_0_0)_0%,oklch(0.02_0_0)_62%,transparent_78%_),radial-gradient(_11%_14%_at_50%_48%,transparent_58%,oklch(0.95_0.02_235_/_0.85)_68%,transparent_80%_),radial-gradient(_32%_21%_at_50%_45%,oklch(0.68_0.14_240_/_0.55)_0%,transparent_72%_),radial-gradient(_70%_55%_at_50%_92%,oklch(0.3_0.13_265_/_0.5)_0%,transparent_70%_),radial-gradient(_55%_65%_at_50%_45%,oklch(0.08_0.02_255)_0%,transparent_70%_),oklch(0.03_0.015_260)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-radial-gradient(_circle_at_50%_48%,transparent_0_4%,oklch(0.62_0.13_240_/_0.1)_4%_4.6%,transparent_4.6%_9%_)] after:[mix-blend-mode:screen]" />
<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