Skip to content

IrisBackgroundsHalo Ring

Halo Ring

A clean loop of electric-blue neon with a slow comet and a fixed warm glint, over near-black.

halo

Halo Ring

One near-circular loop of light, thin core inside a soft blue body, with only the faintest per-angle wobble left in so it reads as drawn rather than a stock ellipse — sized to hold a wordmark-scale headline inside its own glow. A soft diagonal beam crosses behind it, like a flare passing through glass, over a near-black ground with a faint nebula for depth.

A single comet rides the rim slowly, long tail trailing behind it, and one small warm-pink glint sits fixed low on the ring — the stray colour a real neon tube catches at one join. No pointer: this one breathes on its own rather than waiting to be chased.

  • cool
  • no-pointer
  • seeded
Family
halo
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

example.tsx
import { HaloRingField } from "./HaloRingField";

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">
      <HaloRingField />
    </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 halo — one clean, near-circular loop of electric-blue neon
 * over a near-black ground, sized to sit a wordmark-scale headline inside
 * its own glow, the way a product shot leans a claim against a lit ring
 * instead of a plain field.
 *
 * The circle is real geometry, not a texture: a thin hot core inside a
 * soft blue body, with only the faintest per-angle wobble left in so it
 * reads as drawn rather than a stock ellipse. A single comet travels the
 * rim slowly, long tail trailing behind it, and one small warm-pink glint
 * sits fixed low on the ring — the same kind of stray colour a real neon
 * tube catches at one join. A soft diagonal beam of light crosses behind
 * the loop, like a flare passing through glass. No pointer: this one is a
 * still-life that breathes on its own, not something to chase with a
 * cursor.
 *
 * 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-haloringfield__floor`
 * underneath visible — a still ring in the same palette, never a blank box.
 *
 * Like `CascadeField` and `ApertureField`, the palette is a fixed set
 * passed as uniforms rather than read from the accent ramp — this ring's
 * electric blue is its own mood, not the portfolio's amber.
 *
 * 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.009, 0.024], // near-black, cool blue-violet
  u_ringBody: [0.27, 0.56, 1.0], // the loop's electric-blue body
  u_ringCore: [0.88, 0.95, 1.0], // its hot near-white core
  u_glow: [0.05, 0.12, 0.32], // dim ambient aura, filling the loop's inside
  u_ember: [1.0, 0.35, 0.52], // the fixed warm-pink glint low on the ring
};

const FRAG = `
uniform vec2  u_res;
uniform float u_time;
uniform float u_scale;

uniform vec3 u_ground;
uniform vec3 u_ringBody;
uniform vec3 u_ringCore;
uniform vec3 u_glow;
uniform vec3 u_ember;

uniform vec4  u_readA;
uniform float u_guard;

#define PI 3.14159265

mat2 rot(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }

void main() {
  vec2 res = u_res / u_scale;
  vec2 uv  = gl_FragCoord.xy / u_scale / res;           /* 0..1, y up */
  vec2 sc  = vec2(res.x / max(res.y, 1.0), 1.0);
  vec2 q   = uv * sc;

  /* the loop: near-circular, barely tilted, its radius carrying only a
     faint per-angle wobble so it reads as drawn rather than a stock
     ellipse — sampled at a point ON the unit circle (cos/sin of the
     angle) so the noise is seamless where the angle wraps from PI back
     to -PI. */
  vec2 centre = vec2(0.5 * sc.x, 0.58);
  vec2 p = rot(-0.035) * (q - centre);

  const float RADIUS = 0.315;
  vec2 pn  = p / RADIUS;
  float rr = length(pn);
  float ang = atan(pn.y, pn.x);
  vec2 dir = vec2(cos(ang), sin(ang));

  float wobble = fbm(dir * 2.4 + vec2(0.0, u_time * 0.02)) * 0.014;
  float dNorm = rr - (1.0 + wobble);
  float d = dNorm * RADIUS;

  float thick = 0.88 + 0.12 * (0.5 + 0.5 * fbm(dir * 5.0 - vec2(u_time * 0.015, 3.7)));
  float coreW = 0.0068 * thick;
  float bodyW = 0.0195 * thick;
  float glowW = 0.13   * thick;

  /* one comet, long tail trailing behind it */
  float a1 = mod(u_time * 0.085, 2.0 * PI) - PI;
  float diff1 = ang - a1; diff1 -= 2.0 * PI * floor((diff1 + PI) / (2.0 * PI));
  float sigma1 = diff1 > 0.0 ? 0.48 : 0.085;
  float boost = exp(-diff1 * diff1 / (sigma1 * sigma1));

  /* a small warm-pink glint, fixed low on the ring — the stray colour a
     real neon tube catches at one join */
  float aGlint = -1.15;
  float diffG = ang - aGlint; diffG -= 2.0 * PI * floor((diffG + PI) / (2.0 * PI));
  float glint = exp(-diffG * diffG / (0.05 * 0.05));

  vec3 cometCol = mix(u_ringBody, u_ember, clamp(glint * 1.4, 0.0, 1.0));

  float core = exp(-d * d / (coreW * coreW)) * (1.0 + boost * 2.2 + glint * 1.6);
  float body = exp(-d * d / (bodyW * bodyW)) * (1.0 + boost * 1.1 + glint * 0.9);
  float halo = exp(-abs(d) / glowW);

  /* interior aura, concentrated toward the centre and fading out well
     before it reaches the loop itself */
  float inside = 1.0 - smoothstep(0.35, 1.05, rr);
  float aura = inside * inside * 0.42;

  /* a soft diagonal beam crossing behind the loop, like a flare passing
     through glass — a band around one line through the frame, not a
     texture */
  vec2 beamDir = vec2(cos(0.62), sin(0.62));
  vec2 toBeam = q - vec2(0.42 * sc.x, 0.30);
  float beamPerp = toBeam.x * beamDir.y - toBeam.y * beamDir.x;
  float beam = exp(-beamPerp * beamPerp / (0.16 * 0.16)) * 0.10;

  /* a faint, flat nebula in the ground, for depth under the loop —
     kept subtle so the ground stays close to flat near-black */
  vec2 np = q * 0.7 + vec2(0.0, -u_time * 0.01);
  float neb = fbm(np) * 0.5 + fbm(np * 2.3 + 4.0) * 0.5;
  float nebDensity = smoothstep(0.08, 0.6, neb) * 0.05;

  vec3 col = u_ground;
  col += u_glow * nebDensity;
  col += u_glow * aura;
  col += u_ringBody * beam;
  col += u_ringBody * halo * 0.42;
  col += cometCol * body;
  col += mix(u_ringCore, cometCol, clamp(glint * 1.2, 0.0, 1.0)) * core;

  col *= mix(1.0, 0.78, smoothstep(0.55, 1.2, length(uv - 0.5)));
  col = max(col, 0.0);

  /* ---- the reading guard (see TileField for the full rationale) ---- */
  vec2 rd = abs(uv - u_readA.xy) / max(u_readA.zw, vec2(0.02));
  float m = mix(max(rd.x, rd.y), length(rd), 0.4);
  float band = 1.0 - smoothstep(0.72, 2.1, m);
  col = mix(col, holdUnder(col, 0.09), band * u_guard);

  col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.2 / 255.0);

  gl_FragColor = vec4(col, 1.0);
}
`;

export interface HaloRingFieldProps {
  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 HaloRingField({
  className,
  guardSelector = null,
}: HaloRingFieldProps) {
  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 CascadeField / ApertureField: once
         painted, the last frame stays on screen while the surface is parked
         off-view, so scrolling away and back does not crossfade to the
         static floor. A lost context still clears it below. */
      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:oklch(0.045_0.025_264)] before:content-[''] before:absolute before:[inset:43%_auto_auto_50%] before:[width:min(62vh,76%)] before:[aspect-ratio:1.18_/_1] before:[transform:translate(-50%,-50%)_rotate(-6deg)] before:[border-radius:50%] before:[border:2px_solid_oklch(0.78_0.17_250_/_0.85)] before:[box-shadow:0_0_40px_6px_oklch(0.62_0.22_255_/_0.55),0_0_90px_22px_oklch(0.5_0.2_255_/_0.3),inset_0_0_60px_10px_oklch(0.35_0.16_255_/_0.35)] after:content-[''] after:absolute after:inset-0 after:[background:radial-gradient(_30%_26%_at_50%_43%,oklch(0.4_0.14_255_/_0.35)_0%,transparent_70%_)]" />
      <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