Skip to content

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.

gravity

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.

  • cool
  • no-pointer
  • gravity
  • orbit
Family
gravity
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

example.tsx
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>
  );
}

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