Skip to content

IrisBackgroundsSingularity

Singularity

A black oval horizon with a crisp rim, a fountain of thin rays fanning up off it, and — below — a woven grid funnelling into a narrow throat before it blooms back out into a starlit nebula. Built to one reference photo's own hourglass shape.

gravity

Singularity

The horizon sits upper-centre as a flat black oval with a crisp bright rim traced tight around its edge, rather than the catalogue's usual face-on accretion disk. Above it, a wide fan of thin white-blue rays fountains up and outward across the whole top of the frame — some short, some running near to the edge, each its own length and its own flicker, the way real light escaping the rim would never come in one uniform length. Below it, a woven grid of meridian lines and drifting latitude rings funnels down from the horizon's own width into a narrow throat, then opens back out into a bloom of nebula gas and scattered stars at the bottom edge — one continuous silhouette, mouth to throat to bloom.

Every motion here is self-sustaining — the rays' own flicker, the throat's own inward drift, the stars' own twinkle — there is no pointer interaction, unlike the rest of the catalogue's cursor-driven fields.

  • cool
  • no-pointer
  • gravity
  • grid
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 { SingularityField } from "./SingularityField";

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">
      <SingularityField />
    </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 built to the shape of one specific reference
 * photo: a black oval horizon with a layered, bloomed rim sitting
 * upper-centre, a fan of soft volumetric light shafts off that rim across
 * the whole top of the frame, and — below the horizon — a woven, turbulent
 * grid that funnels into a narrow throat and blooms back out into a
 * starlit nebula at the bottom edge. One continuous silhouette,
 * mouth-to-throat-to-bloom, rendered for HDR-style depth rather than flat
 * vector lines: every glow is three stacked falloffs (a tight hot core, a
 * soft mid halo, a wide ambient bloom), every edge carries a little organic
 * turbulence so nothing reads as a perfect drafted curve, motion is slow
 * fbm drift rather than a sine-wave twinkle, and the whole frame runs
 * through a soft tonemap so highlights roll off instead of clipping flat.
 *
 * Every motion is autonomous and unhurried — the rays drift, the throat's
 * latitude bands crawl slowly inward, the whole horizon breathes by about
 * one percent. `u_pointer` isn't read by this shader — no pointer
 * interaction.
 *
 * 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-singularityfield__floor`
 * underneath visible — a still frame in the same palette, never a blank box.
 *
 * 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 — the reference photo is a strict
   monochrome blue/white/black, nothing in the accent ramp carries that.
   Three tiers of brightness (deep, line, hot) rather than two, so the glow
   has tonal depth instead of reading as a single flat hue. */
const PALETTE: Record<string, [number, number, number]> = {
  u_ground: [0.004, 0.005, 0.011], // near-black, cool space
  u_void: [0.0, 0.0, 0.0], // the flat black horizon
  u_hot: [0.94, 0.97, 1.0], // the brightest tier — rim core, ray cores
  u_line: [0.42, 0.66, 0.98], // the mid tier — ray bodies, near grid
  u_deep: [0.06, 0.17, 0.46], // the deep tier — ambient haze, far grid
  u_star: [0.9, 0.95, 1.0], // the background starfield
  u_nebula: [0.04, 0.11, 0.34], // the bloom where the throat opens out at the bottom
};

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_hot;
uniform vec3 u_line;
uniform vec3 u_deep;
uniform vec3 u_star;
uniform vec3 u_nebula;

uniform vec4  u_readA;
uniform float u_guard;

/* soft periodic line at every integer of x — a gaussian well, not a hard
   edge, so the grid reads as glowing energy rather than drafted wireframe */
float sline(float x, float w) {
  float f = fract(x);
  float d = min(f, 1.0 - f);
  return exp(-(d * d) / (w * w));
}

const int RAYS = 14;

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;

  /* a one-percent breathing scale — the whole object is very slightly
     alive rather than a frozen render */
  float breathe = 1.0 + 0.01 * sin(u_time * 0.05);

  vec2 C = vec2(0.5 * aspect, 0.64);
  vec2 p = (q - C) / breathe;

  vec3 col = u_ground;

  /* a faint starfield, drifting and twinkling slowly */
  vec2 sp = uv * vec2(150.0, 95.0) + vec2(u_time * 0.0025, 0.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.5 + 0.5 * sin(u_time * (0.3 + sh * 0.5) + sh * 40.0), 0.0);
    float star = exp(-d * d * 700.0) * twinkle;
    col += u_star * star * (sh - 0.965) * 28.0;
  }

  float holeRX = 0.3;
  float holeRY = 0.115;

  float angRim = atan(p.y, p.x);
  float rimTurb = fbm(vec2(cos(angRim) * 2.2, sin(angRim) * 2.2) + u_time * 0.025);

  /* ---- the upper fountain: soft volumetric shafts, a few structured
     hero rays over a continuous, noise-driven ambient fan ------------- */
  if (p.y > -holeRY * 1.1) {
    vec2 up = vec2(p.x, p.y + holeRY * 0.15);
    float rr = length(up);
    float fanAng = atan(up.x, up.y);                 /* 0 = straight up */

    /* the ambient haze: a continuous fan, denser toward the centre,
       textured by slow fbm rather than N discrete identical rays */
    float fanMask = smoothstep(1.55, 0.0, abs(fanAng));
    float density = fbm(vec2(fanAng * 2.6, rr * 1.2 - u_time * 0.02)) * 0.5 + 0.5;
    float ambient = fanMask * density * exp(-rr * 0.85);
    col += mix(u_deep, u_line, 0.5) * ambient * 0.4;

    for (int i = 0; i < RAYS; i++) {
      float fi = float(i);
      float u01 = (fi + 0.5) / float(RAYS);
      float jitterA = (dotHash(vec2(fi, 1.3)) - 0.5) * 0.06;
      float angle = (u01 - 0.5) * 2.9 + jitterA;      /* ~166°, centred up */
      vec2 dir = vec2(sin(angle), cos(angle));
      vec2 perp = vec2(dir.y, -dir.x);
      float along = dot(up, dir);
      float off = dot(up, perp);

      float lenHash = dotHash(vec2(fi, 4.7));
      float rayLen = mix(0.5, 1.4, lenHash);
      float thickHash = dotHash(vec2(fi, 9.1));
      /* two stacked gaussians per ray — a tight hot core plus a soft wide
         halo — the cheap single-pass version of a bloom pass */
      float thickness = mix(1400.0, 3600.0, thickHash);
      float core = exp(-off * off * thickness);
      float halo = exp(-off * off * thickness * 0.12) * 0.4;

      float presence = smoothstep(0.0, 0.08, along)
                      * (1.0 - smoothstep(rayLen * 0.55, rayLen, along));
      /* slow organic drift instead of a sine-wave twinkle */
      float drift = fbm(vec2(along * 1.4 - u_time * 0.05, fi * 3.7)) * 0.5 + 0.5;
      float bright = mix(0.35, 1.0, dotHash(vec2(fi, 6.6)));

      vec3 rayCol = mix(u_line, u_hot, core * 0.55);
      col += rayCol * (core + halo) * presence * (0.55 + 0.45 * drift) * bright * 0.62;
    }
  }

  /* a single soft anamorphic streak through the horizon, and a faint
     vertical companion — the lens-flare bar a real long-exposure capture
     of something this bright would actually throw */
  float hStreak = exp(-p.y * p.y * 5200.0) * exp(-abs(p.x) * 1.1);
  float vStreak = exp(-p.x * p.x * 9000.0) * exp(-abs(p.y) * 2.4);
  col += u_hot * hStreak * 0.22 + u_line * vStreak * 0.1;

  /* ---- the lower throat: a woven, turbulent grid funnelling to a neck,
     then a haze-filled bloom back out toward the bottom edge ----------- */
  if (p.y < holeRY * 0.4) {
    float belowStart = -holeRY * 0.4;
    float denom = max(belowStart + C.y, 1e-3);
    float t = clamp((belowStart - p.y) / denom, 0.0, 1.0);

    float neckT = 0.45;
    float neckW = 0.018;
    float wTop = holeRX * 0.92;
    float bloomW = 0.85;
    float width = t < neckT
      ? mix(wTop, neckW, smoothstep(0.0, neckT, t))
      : mix(neckW, bloomW, smoothstep(neckT, 1.0, t));
    float s = p.x / max(width, 1e-4);

    /* gentle turbulence so the funnel wavers like woven fabric, not a
       drafted cone */
    float warp = fbm(vec2(s * 1.6, t * 2.4 - u_time * 0.06));
    float sw = s + warp * 0.12;
    float tw = t + fbm(vec2(t * 3.5, s * 1.8)) * 0.015;

    float meridian = sline(sw * 3.2, 0.11) * step(abs(s), 1.2);
    float latitude = sline(tw * 7.0 - u_time * 0.1, 0.16) * smoothstep(1.3, 0.85, abs(s));
    float gridLine = meridian * 0.75 + latitude * 0.55;

    /* a soft haze filling the throat's cross-section, independent of the
       line pattern — volume, not just wireframe */
    float haze = exp(-abs(s) * 2.0) * mix(0.06, 0.3, 1.0 - t);

    float neckGlow = exp(-pow(t - neckT, 2.0) * 40.0);
    float fall = mix(1.0, 0.45, smoothstep(0.0, 1.0, t));
    vec3 throatCol = mix(u_deep, mix(u_line, u_hot, neckGlow * 0.6), neckGlow);
    col += throatCol * gridLine * fall * 0.55;
    col += u_deep * haze * fall * 0.7;
  }

  /* the nebula bloom where the throat opens back out at the bottom edge */
  float nebNoise = fbm(vec2(q.x * 1.4, q.y * 1.4 - u_time * 0.03)) * 0.5 + 0.5;
  float nebNoise2 = fbm(vec2(q.x * 3.1 - 4.0, q.y * 3.1 + u_time * 0.015)) * 0.5 + 0.5;
  float nebMask = smoothstep(0.48, -0.05, uv.y);
  col += u_nebula * nebMask * (0.4 + 0.5 * nebNoise + 0.25 * nebNoise2) * 1.05;

  /* the horizon itself: a black oval with a little organic turbulence on
     its edge, occluding, with a layered bloomed rim rather than one hard
     ring — a tight hot core, a soft mid halo, a wide ambient glow */
  vec2 pe = p / vec2(holeRX, holeRY);
  float e = length(pe) * (1.0 + rimTurb * 0.035);
  float angMod = 0.8 + 0.2 * fbm(vec2(cos(angRim) * 3.1, sin(angRim) * 3.1) + u_time * 0.015);

  float coreRim = exp(-pow(e - 1.0, 2.0) * 320.0);
  float midRim  = exp(-pow(e - 1.0, 2.0) * 60.0);
  float softRim = exp(-pow(e - 1.0, 2.0) * 10.0);

  col += u_deep * softRim * angMod * 0.55;
  col += u_line * midRim * angMod * 0.85;
  col += mix(u_line, u_hot, 0.7) * coreRim * angMod * 1.5;

  /* a whisper of chromatic split right at the hottest edge — the lens
     artefact a real bright rim would actually carry */
  float eR = length(pe + vec2(0.0018, 0.0)) * (1.0 + rimTurb * 0.035);
  float eB = length(pe - vec2(0.0018, 0.0)) * (1.0 + rimTurb * 0.035);
  col.r += exp(-pow(eR - 1.0, 2.0) * 320.0) * 0.12;
  col.b += exp(-pow(eB - 1.0, 2.0) * 320.0) * 0.12;

  /* inside the horizon: not flat #000 but a whisper of deep-space gradient,
     so it reads as a void with depth rather than a pasted cutout */
  vec3 insideCol = mix(u_void, u_ground * 0.4, smoothstep(0.0, 1.0, e));
  float inside = 1.0 - smoothstep(0.965, 1.02, e);
  col = mix(col, insideCol, inside);

  /* corner vignette */
  vec2 corner = uv - 0.5;
  float cvig = length(corner * vec2(1.0, 1.15));
  col *= mix(1.0, 0.6, smoothstep(0.55, 1.1, cvig));

  /* crush the black point a hair, then a soft filmic roll-off so the
     brightest cores bloom rather than clip flat */
  col = max(col - vec3(0.0015), 0.0);
  col = col / (1.0 + col * 0.55);

  /* ---- 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 SingularityFieldProps {
  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 SingularityField({ className, guardSelector = null }: SingularityFieldProps) {
  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 EventHorizonField: 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(_26%_11%_at_50%_36%,oklch(0.02_0_0)_0%,oklch(0.02_0_0)_60%,transparent_74%_),radial-gradient(_30%_13%_at_50%_36%,transparent_62%,oklch(0.95_0.02_235_/_0.9)_70%,transparent_80%_),radial-gradient(_62%_68%_at_50%_-6%,oklch(0.72_0.11_235_/_0.4)_0%,transparent_62%_),radial-gradient(_20%_42%_at_50%_56%,oklch(0.5_0.14_240_/_0.35)_0%,transparent_72%_),radial-gradient(_78%_52%_at_50%_97%,oklch(0.32_0.15_250_/_0.55)_0%,transparent_70%_),oklch(0.03_0.015_260)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-conic-gradient(_from_191deg_at_50%_36%,transparent_0deg_4deg,oklch(0.85_0.05_235_/_0.2)_4deg_4.6deg,transparent_4.6deg_8deg_)] after:[-webkit-mask-image:radial-gradient(_65%_65%_at_50%_6%,#000_0%,#000_55%,transparent_85%_)] after:[mask-image:radial-gradient(_65%_65%_at_50%_6%,#000_0%,#000_55%,transparent_85%_)] 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