Skip to content

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.

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.

Details

Kind
Gravity
Stack
React · TypeScript
Depends on
Nothing
Licence
Free
Added
13 Sep 2026
Tags
warm-cool, pointer-driven, gravity, jet

Its light

  • 18%
  • 32%
  • 20%
  • 21%
  • 9%

Code

Install

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

Usage

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>
  );
}

Nearest in light

More gravity 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