Skip to content

IrisBackgroundsArc Lights

Arc Lights

Three lights riding an invisible arc, their colours merging where they overlap.

glow

Arc Lights

Three lights are each constrained to a position along a half-ellipse — height is a consequence of where they are, not a second animation to keep in sync. Each runs a seeded random walk, so a reduced-motion visitor gets a real composed frame rather than a frozen effect.

Where two lights overlap, the shader takes the energy-weighted mean of their colours: the bridge between them is a genuinely new colour. The pointer joins that same merge as a fourth light rather than sitting over it as a glow.

  • warm-cool
  • merging-colour
  • seeded
Family
glow
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <ArcLightsField />
    </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 field of three lights riding an invisible half-ellipse arc —
 * the standalone version of the geometry `components/contact/ContactField`
 * built for the page's closing section, generalised into a drop-in
 * background and given a real pointer.
 *
 * Each light owns a position ALONG the arc and nothing else — height is a
 * consequence of where it is, not a second animation kept in sync. The
 * travel is a seeded random walk simulated on the CPU (a target speed
 * re-drawn at random intervals, eased into, reflected at both ends of the
 * arc so a turnaround decelerates through zero), which is why a
 * reduced-motion visitor still gets a real composed frame rather than a
 * frozen effect: the still draw replays the same simulation from zero.
 *
 * Where lights overlap, the shader takes the ENERGY-WEIGHTED MEAN of their
 * colours rather than crossfading — the bridge between two lights is a
 * genuinely new hue, formed and dissolved out of the same numbers that move
 * them. See ContactField's doc comment for the full reasoning.
 *
 * The pointer is a real member of that same merge, not an overlay: a fourth
 * light follows the cursor and wins its own share of the energy-weighted
 * mean, so a visitor's touch mixes into whichever colour it lands near. The
 * three real lights also lean toward it, the same way SilkField's folds or
 * EmberField's sparks bend toward a passing 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-arclightsfield__floor`
 * underneath visible.
 *
 * Palette: the same three tokens `ContactField` uses (aliases of the
 * trajectory section's role colours, in app/globals.css), so the standalone
 * piece and the site's own closing field are lit by the same three lights.
 *
 * 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.
 */

/* The arc, in the canvas's own uv (y up). Same shape ContactField uses —
   feet past the horizontal edges, so it reads as a rising curve rather than
   a shape sitting on the floor. */
const ARC_HALF = 0.58;
const ARC_BASE = 0.02;
const ARC_RISE = 0.46;

/* One overlapping sector per light rather than one range shared by all
   three — see ContactField for why: three independent walks over the whole
   arc spend most of their time bunched at one end. */
const SECTORS: ReadonlyArray<readonly [number, number]> = [
  [0.06, 0.44],
  [0.28, 0.72],
  [0.56, 0.94],
];

const arcY = (x: number) => {
  const k = (x - 0.5) / ARC_HALF;
  return ARC_BASE + ARC_RISE * Math.sqrt(Math.max(0, 1 - k * k));
};

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 */

/* Per real light: xy = position in uv, z = radius in height units, w =
   strength. Simulated on the CPU — the walk is stateful and random, which
   is not a thing a fragment shader can carry between frames. */
uniform vec4  u_light[3];

uniform vec3 u_ground;
uniform vec3 u_c1;
uniform vec3 u_c2;
uniform vec3 u_c3;
uniform vec3 u_touch;   /* the pointer's own light, always this colour, so a
                            visitor can tell their touch from the three that
                            were already there */

uniform vec4  u_readA;
uniform float u_guard;

/* One light's contribution at a point — a gaussian rather than a hard edge,
   because the SUM of gaussians is what fuses two nearby lights into one
   body with no test for whether they are "touching". */
float lightAt(vec4 L, vec2 q, vec2 sc) {
  vec2 d = q - L.xy * sc;
  return exp(-dot(d, d) / max(L.z * L.z, 1e-4)) * L.w;
}

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);       /* aspect, in height units */
  vec2 q   = uv * sc;

  /* the touch bends the space itself: nearby real lights lean toward the
     cursor, the way SilkField's folds or EmberField's sparks bend toward a
     passing hand. The touch's own light below is drawn at the unwarped
     point, so it always sits exactly under the cursor. */
  vec2 ptr   = u_pointer.xy * sc;
  vec2 toP   = q - ptr;
  float pull = u_pointer.z * exp(-dot(toP, toP) * 10.0);
  vec2 qw    = q - toP * pull * 0.22;

  /* a slow domain warp keeps each light an organic blob rather than a
     drawable ellipse — applied to the (already leaning) point, so it
     deforms the merged body as one shape */
  float t = u_time * 0.085;
  vec2 warp = vec2(
    fbm(qw * 1.9 + vec2(0.0, t)),
    fbm(qw * 1.9 + vec2(4.3, -t) + 9.1)
  );
  qw += warp * 0.09;

  float w1 = lightAt(u_light[0], qw, sc);
  float w2 = lightAt(u_light[1], qw, sc);
  float w3 = lightAt(u_light[2], qw, sc);

  /* the pointer's own light: an equal member of the same merge, not an
     overlay — it wins its share of the energy-weighted mean like any of
     the other three */
  vec4 touchLight = vec4(u_pointer.xy, 0.30, u_pointer.z * 1.2);
  float w4 = lightAt(touchLight, q, sc);

  float e = w1 + w2 + w3 + w4;

  /* THE MERGE — see ContactField for the full rationale. One line of
     physics, not a collision test: the energy-weighted mean of the four
     colours, so an overlap is a genuinely new hue rather than a crossfade. */
  vec3 tint = (w1 * u_c1 + w2 * u_c2 + w3 * u_c3 + w4 * u_touch) / max(e, 1e-4);

  float shared = e - max(max(max(w1, w2), w3), w4);
  float fused  = smoothstep(0.08, 0.50, shared);
  float core   = smoothstep(0.25, 1.00, e);
  float glow   = 1.0 - exp(-e * 1.7);

  vec3 col = u_ground;
  col += tint * glow * (0.85 + 0.4 * core + 0.6 * fused);

  /* a tight, crisp core exactly at the cursor — the difference between the
     touch reading as "a light joined the field" and "the field brightened
     somewhere near my mouse" */
  vec2 dTouch = q - u_pointer.xy * sc;
  float touchCore = exp(-dot(dTouch, dTouch) * 46.0) * u_pointer.z;
  col += u_touch * touchCore * 0.55;

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

/* The three lights, aliased from the role palettes — same tokens
   ContactField uses, in app/globals.css § "The closing field". */
const PALETTE = [
  "--shader-contact-a",
  "--shader-contact-b",
  "--shader-contact-c",
] as const;

/** Deterministic PRNG, so a seed reproduces a walk exactly. */
function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let x = Math.imul(a ^ (a >>> 15), 1 | a);
    x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
    return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
  };
}

interface Light {
  /** Position along the arc, 0–1. The only degree of freedom it has. */
  x: number;
  min: number;
  max: number;
  v: number;
  vTarget: number;
  next: number;
  rng: () => number;
  phase: number;
  radius: number;
}

/* Different base radii, so the three are not interchangeable and a merge
   between a big one and a small one looks different from the reverse. */
const RADII = [0.36, 0.3, 0.34];

function makeLights(): Light[] {
  return RADII.map((radius, i) => {
    const rng = mulberry32(0x9e37 + i * 7919);
    const [min, max] = SECTORS[i];
    return {
      x: (min + max) * 0.5,
      min,
      max,
      v: 0,
      vTarget: (i % 2 === 0 ? 1 : -1) * (0.035 + rng() * 0.05),
      next: rng() * 1.5,
      rng,
      phase: rng() * 6.283,
      radius,
    };
  });
}

const DT = 1 / 60;

function step(lights: Light[], time: number) {
  for (const s of lights) {
    if (time >= s.next) {
      const dir =
        s.rng() < 0.38 ? -Math.sign(s.vTarget || 1) : Math.sign(s.vTarget || 1);
      s.vTarget = dir * (0.028 + s.rng() * 0.072);
      s.next = time + 1.4 + s.rng() * 3.8;
    }
    s.v += (s.vTarget - s.v) * (1 - Math.exp(-DT / 0.55));
    s.x += s.v * DT;

    if (s.x < s.min) {
      s.x = s.min + (s.min - s.x);
      s.vTarget = Math.abs(s.vTarget);
    } else if (s.x > s.max) {
      s.x = s.max - (s.x - s.max);
      s.vTarget = -Math.abs(s.vTarget);
    }
  }
}

function pack(lights: Light[], time: number, out: Float32Array) {
  lights.forEach((s, i) => {
    const drift =
      0.052 * Math.sin(time * 0.37 + s.phase) +
      0.03 * Math.sin(time * 0.23 + s.phase * 2.1);
    const breathe = 0.92 + 0.1 * Math.sin(time * 0.44 + s.phase);
    out[i * 4 + 0] = s.x;
    out[i * 4 + 1] = arcY(s.x) + drift;
    out[i * 4 + 2] = s.radius * breathe;
    out[i * 4 + 3] = 1;
  });
}

export interface ArcLightsFieldProps {
  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 ArcLightsField({
  className,
  guardSelector = null,
}: ArcLightsFieldProps) {
  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;
    };

    let lights = makeLights();
    const packed = new Float32Array(12);
    let simTime = 0;

    /* Advances the walk TO a time rather than BY one — rewinding (what a
       still draw does) starts the walk over from its seeds, so the frozen
       frame and the running one are the same frame. */
    const simulateTo = (time: number) => {
      if (time < simTime) {
        lights = makeLights();
        simTime = 0;
      }
      let steps = Math.min(Math.floor((time - simTime) / DT), 1200);
      while (steps-- > 0) {
        simTime += DT;
        step(lights, simTime);
      }
    };

    return mountShaderSurface(canvas, {
      fragment: FRAG,
      colors: {
        u_c1: PALETTE[0],
        u_c2: PALETTE[1],
        u_c3: PALETTE[2],
        u_ground: "--iris-bg-sunken",
      },
      uniforms: ["u_light[0]", "u_touch", "u_readA", "u_guard"],
      onInit: (gl, u) => {
        if (u.u_touch) gl.uniform3f(u.u_touch, 0.97, 0.975, 0.99);
        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) => {
        simulateTo(s.time);
        pack(lights, simTime, packed);
        gl.uniform4fv(u["u_light[0]"], packed);

        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 SilkField / EmberField: 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"),
      stillTime: 9,
    });
  }, [guardSelector]);

  return (
    <div
      className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
      aria-hidden="true"
    >
      <div className="absolute inset-0 [background:radial-gradient(_38%_62%_at_18%_68%,var(--shader-contact-a)_0%,transparent_68%_),radial-gradient(_42%_68%_at_50%_30%,var(--shader-contact-b)_0%,transparent_68%_),radial-gradient(_38%_62%_at_82%_68%,var(--shader-contact-c)_0%,transparent_68%_),var(--iris-bg-sunken)]" />
      <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