Skip to content

IrisBackgroundsOrbital Arc

Orbital Arc

A huge ring of light centred above the frame, its lower rim sweeping through the middle in a layered white-violet-blue glow with one comet riding it.

arc

Orbital Arc

Only the lower rim of an enormous ring is ever in frame: it enters near the upper-left, dips through the middle of the composition, and rises back out toward the upper-right — the way a product mark suspends a ring of light behind its wordmark instead of drawing a flat line across it. The rim itself is four layers, not a stroke: a thin near-white core, a close violet halo, an electric-blue bloom that occupies far more space than the line itself, and a huge, soft atmospheric tail beyond that. Two smaller, dimmer rings sit further inside it, mostly blue, partly lost in the glow, so the composition reads as light nested in light.

One point of energy rides the primary rim, its position the same parametric angle the rim is shaded by, so its comet tail is a brightness boost in that angle's own space rather than a second animation kept in sync by hand. It sweeps slowly between the two flanks rather than looping, and a second, dimmer point drifts along the innermost ring.

  • cool
  • no-pointer
  • layered-glow
Family
arc
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <OrbitalArcField />
    </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 orbital arc — a huge ring of light centred above the frame,
 * so only its lower rim is ever visible: sweeping in from the upper-left,
 * dipping through the middle, and back up toward the upper-right, the way a
 * product mark suspends a ring of light behind its wordmark rather than
 * drawing a flat line across it.
 *
 * The rim itself is layered, not a stroke: a thin near-white core with its
 * own tighter specular peak inside it, a violet halo close around that, an
 * electric-blue bloom occupying far more space than the line itself, and a
 * huge, soft atmospheric tail beyond that — the same four-layer hierarchy
 * real suspended light reads as, never a flat coloured line. The rim is not
 * a uniform-width tube either: it stays tight and hot at the trough and
 * fans out — wider core, wider halo, wider bloom, all with their own soft
 * gradient — toward the two flanks, the way a suspended ring foreshortens
 * and spreads as it turns away from camera near the top corners.
 *
 * A dimmer, much smaller ellipse sits further inside the primary ring's
 * glow, mostly lost in it, so the composition reads as light nested in
 * light rather than one ring alone.
 *
 * One point of energy — a hot white core inside a violet halo — rides the
 * primary rim, oscillating slowly between the two flanks rather than
 * looping; its screen position is the SAME parametric angle used to shade
 * the rim, so its comet tail is a brightness boost in that angle's own
 * space and not a second animation kept in sync by hand.
 *
 * 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-orbitalarcfield__floor`
 * underneath visible — a static rim in the same palette, never a blank box.
 *
 * Like `HaloRingField` and `CascadeField`, the palette is a fixed set
 * passed as uniforms rather than read from the accent ramp — this piece's
 * navy-to-violet-to-white mood is its own, 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.005, 0.007, 0.02], // near-black, cool navy
  u_core: [0.93, 0.96, 1.0], // the rim's near-white centre
  u_violet: [0.64, 0.32, 0.97], // the halo close around the core
  u_blue: [0.16, 0.42, 1.0], // the electric-blue bloom
  u_atmosphere: [0.045, 0.11, 0.38], // the huge, soft atmospheric tail
};

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

uniform vec3 u_ground;
uniform vec3 u_core;
uniform vec3 u_violet;
uniform vec3 u_blue;
uniform vec3 u_atmosphere;

uniform vec4  u_readA;
uniform float u_guard;

#define PI 3.14159265

/* Distance to an ellipse's rim, approximated by scaling into a circle of
   radius ry first — exact ON the rim, and smooth enough off it for a glow
   that never needs a true Euclidean distance. Cheap: no iteration, no
   ray marching, just the handful of ops below, evaluated a couple of times
   per pixel for the two rings this field draws. */
float ellipseDist(vec2 p, float rx, float ry) {
  vec2 s = vec2(p.x * (ry / rx), p.y);
  return length(s) - ry;
}

/* The same parametric angle the rim's own x = rx*cos, y = ry*sin is built
   from, so anything keyed to this angle (the comet's position, its tail)
   is guaranteed to sit exactly on the curve rather than merely near it. */
float ellipseAngle(vec2 p, float rx, float ry) {
  return atan(p.y / ry, p.x / rx);
}

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;

  vec3 glow = vec3(0.0);

  /* ---- primary arc — the dominant ring, centred well above the frame ---- */
  vec2 cA = vec2(0.5 * sc.x, 1.70);
  const float RY_A = 1.30;
  const float RX_A = 1.15;
  vec2 pA = q - cA;
  float dA = ellipseDist(pA, RX_A, RY_A);
  float angA = ellipseAngle(pA, RX_A, RY_A);

  /* the hotspot: one point of energy riding the primary rim, its position
     the SAME angle the rim itself is shaded by — never an independent x/y
     animation. It sweeps slowly between the two flanks rather than looping
     all the way round, which is why sin() alone drives it: a seamless,
     reflecting back-and-forth with no seam to hide. */
  float speed = 0.11;
  float thetaPhase = u_time * speed;
  float theta = -PI * 0.5 + 1.05 * sin(thetaPhase);
  float dirSign = sign(cos(thetaPhase));

  vec2 hp = cA + vec2(RX_A * cos(theta), RY_A * sin(theta));

  /* comet trail: an asymmetric boost in the rim's own angle space — narrow
     ahead of the hotspot, long behind it, the tail flipping with the
     direction of travel. Same technique HaloRingField's comet uses. */
  float diffA = angA - theta;
  diffA -= 2.0 * PI * floor((diffA + PI) / (2.0 * PI));
  float isBehind = step(0.0, -diffA * dirSign);
  float trailSigma = mix(0.05, 0.42, isBehind);
  float trail = exp(-diffA * diffA / (trailSigma * trailSigma));

  /* the rim is not a uniform-width tube: it stays tight and hot at the
     trough and FANS OUT toward the two flanks, the way a suspended ring of
     light foreshortens and spreads as it turns away from camera near the
     top corners. edge is 0 at the trough, 1 out at the flanks. */
  float angOff = angA + PI * 0.5;
  angOff -= 2.0 * PI * floor((angOff + PI) / (2.0 * PI));
  float edge = smoothstep(0.1, 1.15, abs(angOff));

  float coreW  = 0.0048 * (1.0 + edge * 0.55);
  float innerW = 0.016  * (1.0 + edge * 0.9);
  float outerW = 0.075  * (1.0 + edge * 1.9);
  float atmoW  = 0.26   * (1.0 + edge * 1.6);

  float core  = exp(-dA * dA / (coreW  * coreW))  * (1.0 + trail * 2.2);
  float inner = exp(-dA * dA / (innerW * innerW)) * (1.0 + trail * 1.0);
  float outer = exp(-dA * dA / (outerW * outerW)) * (1.0 + trail * 0.4);
  float atmo  = exp(-dA * dA / (atmoW  * atmoW));

  /* a second, much tighter peak riding inside the core — the difference
     between a soft glowing tube and an actual specular hot line */
  float specW = coreW * 0.4;
  float spec  = exp(-dA * dA / (specW * specW));

  glow += u_atmosphere * atmo  * (0.22 + edge * 0.34);
  glow += u_blue       * outer * (0.5  + edge * 0.4);
  glow += u_violet      * inner * (0.85 + edge * 0.25);
  glow += u_core        * core  * 1.25;
  glow += u_core        * spec  * 0.7;

  /* a crisp, radial particle exactly at the hotspot — the difference
     between "a segment of the rim brightened" and "something is actually
     travelling along it" */
  vec2 dh = q - hp;
  float hpHalo = exp(-dot(dh, dh) / 0.007);
  float hpCore = exp(-dot(dh, dh) / 0.0012);
  glow += u_violet * hpHalo * 0.4;
  glow += u_core    * hpCore * 0.85;

  /* ---- secondary arc — smaller, mostly blue, lower intensity ---- */
  vec2 cB = vec2(0.46 * sc.x, 1.56);
  const float RY_B = 0.95;
  const float RX_B = 0.85;
  vec2 pB = q - cB;
  float dB = ellipseDist(pB, RX_B, RY_B);
  float outerB = exp(-dB * dB / (0.045 * 0.045));
  float atmoB  = exp(-dB * dB / (0.16 * 0.16));
  glow += u_atmosphere * atmoB * 0.14;
  glow += u_blue       * outerB * 0.22;

  /* a faint atmospheric wash filling the centre of the composition */
  vec2 centre = vec2(0.5 * sc.x, 0.5);
  float dCentre = length(q - centre);
  float centreGlow = exp(-dCentre * dCentre / (0.4 * 0.4));
  glow += u_atmosphere * centreGlow * 0.06;

  /* a slow, tiny breathing in overall energy — never fast, never uniform */
  float breathe = 1.0 + 0.035 * sin(u_time * 0.21);
  glow *= breathe;

  vec3 col = u_ground + glow;
  col *= mix(1.0, 0.5, smoothstep(0.4, 1.15, 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 OrbitalArcFieldProps {
  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 OrbitalArcField({
  className,
  guardSelector = null,
}: OrbitalArcFieldProps) {
  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 HaloRingField / CascadeField: 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.035_0.02_264)] before:content-[''] before:absolute before:[left:50%] before:[top:-88%] before:[width:175%] before:[aspect-ratio:1.5_/_1] before:[transform:translateX(-50%)] before:[border-radius:50%] before:[border:2px_solid_oklch(0.92_0.03_275_/_0.9)] before:[box-shadow:0_0_46px_8px_oklch(0.62_0.23_280_/_0.55),0_0_110px_26px_oklch(0.55_0.22_258_/_0.4),0_0_220px_60px_oklch(0.42_0.18_255_/_0.25)] after:content-[''] after:absolute after:inset-0 after:[background:radial-gradient(_55%_42%_at_50%_58%,oklch(0.38_0.14_258_/_0.3)_0%,transparent_72%_)]" />
      <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