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







