Skip to content

IrisBackgroundsQuasar

Quasar

A black horizon sitting left-of-frame, its warm turbulent ring broken open by a single jet running white-hot to ember, a cool nebula in one corner against a warm one gathered behind the jet's own path — the jet leans gently toward the pointer.

gravity

Quasar

Where the rest of the gravity family sits its hole dead centre, this one is framed the way the reference photo frames it: off to the left, wrapped in a warm, tan accretion ring thick with turbulent bands rather than a clean saucer. The ring breaks open on its upper-right side, and through that gap a single bright jet punches out — white-hot right at the horizon, cooling to ember orange as it travels, its own flicker riding along its length. A thin lensed arc echoes the ring's far side over the top of the horizon. The frame's two corners carry the same warm/cool duality as the ring itself: a cool blue nebula gathers in the corner behind the hole, a warm one behind the jet's own path, both drifting through a faint twinkling starfield.

Unlike `event-horizon`, `singularity` and `gargantua`, this one keeps the interactions — small, deliberately: the jet leans a few degrees toward wherever the pointer sits vertically, the two nebula layers drift a few percent of the frame for parallax, and a soft warm glow trails the cursor itself, clipped by the horizon like any other light. Everything else — the ring's swirl, the jet's flicker, the star twinkle — runs on its own.

  • warm-cool
  • pointer-driven
  • gravity
  • jet
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 { QuasarField } from "./QuasarField";

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">
      <QuasarField />
    </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 quasar — built to one reference photo's own off-centre
 * composition rather than the catalogue's usual face-on hole: a black
 * horizon sitting left-of-frame, wrapped in a warm, turbulent accretion ring
 * that breaks open on its upper-right side, a single bright jet punching out
 * through that break — white-hot at the horizon, cooling to ember orange as
 * it travels — and a cool blue nebula filling the frame's opposite corner
 * against a warm one gathered behind the jet's own path. A thin lensed arc
 * echoes the ring's far side over the top of the horizon, and a faint
 * starfield twinkles through both nebulae.
 *
 * Unlike `EventHorizonField` / `SingularityField`, this one *does* answer to
 * the cursor, kept deliberately small: the jet leans a few degrees toward
 * wherever the pointer sits vertically, the two nebula layers drift a few
 * percent of the frame for parallax rather than staying pinned to it, and a
 * soft warm glow trails the pointer itself. Every other motion — the ring's
 * swirl, the jet's flicker, the star twinkle — runs on its own regardless of
 * whether anyone is pointing at it.
 *
 * One of the reusable background fields. 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-quasarfield__floor` underneath visible — a still
 * frame in the same palette, never a blank box.
 *
 * Fixed palette, not the token ramp — like `EventHorizonField` and
 * `SingularityField`, this is a deep-space duality the site's own accent
 * ramp doesn't carry.
 *
 * 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.005, 0.006, 0.014], // near-black, cool deep space
  u_void: [0.0, 0.0, 0.0], // the flat black horizon
  u_diskLit: [0.94, 0.87, 0.74], // the ring's hot, sunlit tan
  u_diskShadow: [0.24, 0.19, 0.16], // the ring's own turbulent shadow
  u_ring: [0.97, 0.95, 0.9], // the photon ring and lensed arc
  u_jetHot: [1.0, 0.98, 0.92], // the jet's white-hot core, near the horizon
  u_jetWarm: [0.98, 0.42, 0.14], // the jet cooling to ember orange
  u_nebulaWarm: [0.5, 0.16, 0.05], // the warm cloud gathered behind the jet
  u_nebulaCool: [0.06, 0.1, 0.24], // the cool cloud in the opposite corner
  u_star: [0.92, 0.95, 1.0], // the background starfield
};

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_void;
uniform vec3 u_diskLit;
uniform vec3 u_diskShadow;
uniform vec3 u_ring;
uniform vec3 u_jetHot;
uniform vec3 u_jetWarm;
uniform vec3 u_nebulaWarm;
uniform vec3 u_nebulaCool;
uniform vec3 u_star;

uniform vec4  u_readA;
uniform float u_guard;

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;

  float pep = u_pointer.z;
  vec2 ptr = u_pointer.xy * sc;

  /* small pointer interactions, all scaled to a few percent of the frame so
     the composition itself never chases the cursor — the far nebula drifts
     for parallax, the near one drifts a little less */
  vec2 driftFar  = (u_pointer.xy - 0.5) * 0.05 * pep;
  vec2 driftNear = (u_pointer.xy - 0.5) * 0.02 * pep;

  vec3 col = u_ground;

  /* a faint starfield, twinkling on its own, drifting a touch with the far
     nebula layer */
  vec2 sp = uv * vec2(150.0, 95.0) - driftFar * 40.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;
    col += u_star * star * (sh - 0.965) * 28.0;
  }

  /* the cool nebula, filling the corner opposite the jet's own path */
  vec2 coolQ = q + driftFar;
  float coolN = fbm(vec2(coolQ.x * 1.1, coolQ.y * 1.1 - u_time * 0.015)) * 0.5 + 0.5;
  float coolMask = smoothstep(0.7, -0.15, uv.x) * smoothstep(0.02, 0.85, uv.y);
  col += mix(u_nebulaCool * 0.55, u_nebulaCool * 1.5, coolN) * coolMask * 0.9;

  /* the warm nebula, gathered behind the jet's own line of travel */
  vec2 warmQ = q + driftFar * 0.7;
  float warmN  = fbm(vec2(warmQ.x * 1.3 - 3.0, warmQ.y * 1.3 + u_time * 0.02)) * 0.5 + 0.5;
  float warmN2 = fbm(vec2(warmQ.x * 2.6 + 7.0, warmQ.y * 2.6 - u_time * 0.03)) * 0.5 + 0.5;
  float warmMask = smoothstep(0.3, 1.05, uv.x);
  col += mix(u_nebulaWarm * 0.5, u_nebulaWarm * 1.5, warmN * 0.6 + warmN2 * 0.4) * warmMask * 1.1;

  /* the hole itself sits left-of-frame, the way the reference photo frames
     it, not dead centre */
  vec2 centre = vec2(0.36 * aspect, 0.5) + driftNear * 0.3;
  vec2 p = q - centre;
  float r = max(length(p), 1e-4);
  float ang = atan(p.y, p.x);

  float holeR = 0.11;

  /* the jet: one beam, not the usual symmetric pair, leaning a few degrees
     toward wherever the pointer sits vertically — a small interaction, not
     a redirect */
  float baseTilt = 0.32;
  float jetBend = (u_pointer.y - 0.5) * 0.22 * pep;
  vec2 jdir = normalize(vec2(1.0, baseTilt + jetBend));
  vec2 jperp = vec2(-jdir.y, jdir.x);
  float jetAngle = atan(jdir.y, jdir.x);
  float along = dot(p, jdir);
  float off = dot(p, jperp);

  /* the accretion ring: tilted, turbulent, warm — broken open on the side
     the jet punches through */
  float tilt = 0.5;
  vec2 pd = vec2(p.x, p.y / tilt);
  float rd = max(length(pd), 1e-4);
  float adisk = atan(pd.y, pd.x);

  float diskInner = holeR * 1.15;
  float diskOuter = holeR * 2.8;
  float diskMask = smoothstep(diskInner - 0.01, diskInner + 0.015, rd)
                  * (1.0 - smoothstep(diskOuter - 0.2, diskOuter, rd));

  float angFromJet = abs(atan(sin(ang - jetAngle), cos(ang - jetAngle)));
  float gapGate = smoothstep(0.0, 0.55, angFromJet);
  diskMask *= gapGate;

  float swirl = fbm(vec2(rd * 3.0, adisk * 1.3 - u_time * 0.05));
  float bands = 0.5 + 0.5 * sin(adisk * 2.0 + rd * 10.0 - u_time * 0.4 + swirl * 1.3);
  float heat = clamp((rd - diskInner) / max(diskOuter - diskInner, 1e-4), 0.0, 1.0);
  vec3 diskCol = mix(u_diskLit, u_diskShadow, heat * 0.7 + (1.0 - bands) * 0.3);

  col += diskCol * diskMask * (0.5 + 0.6 * bands) * 1.05;

  /* the lensed arc: the ring's far side, echoed thin and bright over the
     top of the horizon */
  float haloR = holeR * 1.35;
  float dHalo = r - haloR;
  float haloBand = exp(-dHalo * dHalo * 900.0);
  float topGate = smoothstep(-0.15, 0.35, p.y / r);
  col += mix(u_ring, u_diskLit, 0.4) * haloBand * topGate * 0.9;

  /* the jet proper: widening as it travels, white-hot near the horizon and
     cooling to ember orange further out, its own flicker riding on top */
  float coreWidth = mix(0.014, 0.1, smoothstep(0.0, 1.3, along));
  float jetShape = exp(-(off * off) / (coreWidth * coreWidth));
  float jetGate = smoothstep(holeR * 0.55, holeR * 1.05, along);
  float jetFlicker = 0.6 + 0.4 * fbm(vec2(along * 3.0 - u_time * 0.6, ang * 2.0));
  vec3 jetCol = mix(u_jetWarm, u_jetHot, exp(-along * along * 3.0));
  col += jetCol * jetShape * jetFlicker * jetGate * 1.3;

  /* the photon ring, warm on the jet's side and cool on the far side —
     the same duality the two nebulae carry */
  float ringR = holeR * 1.05;
  float dRing = r - ringR;
  float photonRing = exp(-dRing * dRing * 2600.0);
  float ringWarmth = 0.5 + 0.5 * cos(ang - jetAngle);
  vec3 ringCol = mix(mix(u_ring, u_nebulaCool, 0.25), mix(u_ring, u_jetWarm, 0.35), ringWarmth);
  col += ringCol * photonRing * 1.4;

  /* a soft warm glow trailing the pointer itself — the smallest of the
     interactions, and clipped by the horizon below like any other light */
  float dPtr = distance(q, ptr);
  float bloom = exp(-dPtr * dPtr * 3.0) * pep;
  col += mix(u_jetHot, u_star, 0.3) * bloom * 0.06;

  /* the event horizon: flat black with a little organic turbulence on its
     edge, occluding everything behind it */
  float rimTurb = fbm(vec2(cos(ang) * 2.5, sin(ang) * 2.5) + u_time * 0.02);
  float rEdge = r * (1.0 + rimTurb * 0.02);
  float horizon = 1.0 - smoothstep(holeR - 0.006, holeR + 0.006, rEdge);
  col = mix(col, u_void, horizon);

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

  /* a soft filmic roll-off so the jet and rim bloom rather than clip flat */
  col = max(col, 0.0);
  col = col / (1.0 + col * 0.5);

  /* ---- 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 QuasarFieldProps {
  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 QuasarField({ className, guardSelector = null }: QuasarFieldProps) {
  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 / SingularityField:
         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%_10%_at_36%_50%,oklch(0.02_0_0)_0%,oklch(0.02_0_0)_60%,transparent_78%_),radial-gradient(_11%_14%_at_36%_50%,transparent_55%,oklch(0.94_0.03_75_/_0.85)_68%,transparent_80%_),radial-gradient(_55%_42%_at_70%_26%,oklch(0.72_0.14_45_/_0.5)_0%,transparent_68%_),radial-gradient(_60%_48%_at_80%_58%,oklch(0.42_0.15_40_/_0.55)_0%,transparent_70%_),radial-gradient(_52%_58%_at_12%_20%,oklch(0.3_0.09_250_/_0.4)_0%,transparent_65%_),oklch(0.03_0.015_260)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-conic-gradient(_from_18deg_at_36%_50%,transparent_0deg_6deg,oklch(0.85_0.06_70_/_0.14)_6deg_6.6deg,transparent_6.6deg_12deg_)] 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