Skip to content

IrisBackgroundsAmber Glow

Amber Glow

A single pool of honey-gold light, blurred past the point of having an edge, off-centre on a pale cream ground.

glow

Amber Glow

Built to one specific reference photo: no bands, no rings, just a three-stop radial fall from cream through amber into a saturated gold core, its boundary pushed gently irregular by one low-frequency warp so it reads as an out-of-focus light source rather than a drawn ellipse. The second light-ground field in the catalogue alongside `opal-wash`, and considerably quieter than it — one warm pool instead of a flowing multi-band wash.

The pool leans a few percent toward the cursor, eased rather than snapped, and a second, smaller halo rides the cursor directly — a soft warm highlight that fades in and out with presence alone. Both settle back to a slow, barely-there idle drift the moment the pointer leaves. Its reading guard washes toward cream for dark copy, the same inverse contract as `opal-wash`.

  • warm
  • light-ground
  • pointer-driven
  • minimal
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 { AmberGlowField } from "./AmberGlowField";

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">
      <AmberGlowField />
    </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";

/**
 * Built to one specific reference photo: a single pool of warm honey-gold
 * light sitting off-centre on a pale cream ground, blurred past the point of
 * having an edge — no band, no ring, just a three-stop radial fall from cream
 * through amber into a saturated gold core, its boundary pushed gently
 * irregular by one low-frequency fbm warp so it reads as an out-of-focus
 * light source rather than a drawn ellipse.
 *
 * Like `opal-wash`, this is a light-ground field: the reading guard washes
 * *toward* cream instead of clamping down, because copy laid over it is
 * dark, not light.
 *
 * Two micro-interactions ride the shared pointer from `lib/shader-surface.ts`
 * rather than a heavier flow field: the glow itself leans a few percent
 * toward the cursor (eased, the same lag every pointer-driven field in this
 * catalogue uses, so it never snaps), and a second, smaller highlight rides
 * the cursor directly — a soft warm halo that fades in with presence, the
 * "a light is listening" cue. Both settle back to a slow, near-still idle
 * drift the instant the pointer leaves.
 *
 * Drop it into any `position: relative`/`isolate` parent — it fills the box.
 * Every degradation path `lib/shader-surface.ts` handles is already covered:
 * no WebGL, a blocked or lost context, a hidden tab, or
 * `prefers-reduced-motion` all leave the CSS `.iris-amberglowfield__floor`
 * underneath visible — a still frame in the same three stops, never a blank
 * box.
 */

/* Palette, sRGB 0–1. Uniforms, not tokens — this warm-yellow mood lives
   nowhere in the site's own amber ramp, same reasoning as `opal-wash`. */
const PALETTE: Record<string, [number, number, number]> = {
  u_cream: [0.996, 0.976, 0.906], // pale ivory ground, the corners
  u_amber: [0.984, 0.804, 0.298], // mid transitional warmth
  u_gold: [0.961, 0.706, 0.039], // the saturated core
};

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_cream;
uniform vec3 u_amber;
uniform vec3 u_gold;

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 / max(res.y, 1.0);
  vec2 P = (uv - 0.5) * vec2(aspect, 1.0);

  /* idle drift — a slow ellipse, amplitude small enough to never read as
     motion, same restraint as GradientBackground's default speed */
  float t = u_time * 0.045;
  vec2 drift = vec2(cos(t * 0.8), sin(t * 0.6)) * 0.02;

  /* micro-interaction 1: the pool leans toward the cursor. u_pointer.xy
     is already the eased position lib/shader-surface.ts tracks per frame,
     so this needs no easing of its own — presence alone fades the pull. */
  vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  vec2 pull = ptr * 0.07 * u_pointer.z;

  vec2 blobC = vec2(aspect * 0.30, 0.02) + drift + pull;

  /* one low-frequency warp so the pool's edge reads as an out-of-focus
     light source, not a drawn ellipse — kept subtle, this is a blur
     reference, not a flow field like opal-wash. */
  vec2 warp = vec2(fbm(P * 1.0 + t * 0.3), fbm(P * 1.0 - t * 0.24 + 9.0)) * 0.07;

  /* falloff is cut with an explicit smoothstep on top of the exponential
     tails, not left to decay on its own — a pure gaussian never reaches
     true zero, which is what was casting the whole frame amber instead of
     leaving the corners genuinely cream, the way the reference photo has
     real pale space around the pool rather than a wall-to-wall wash. */
  vec2 d = (P + warp - blobC) / vec2(0.46, 0.38);
  float r2 = dot(d, d);
  float falloff = smoothstep(3.4, 0.0, r2);
  float core = exp(-r2 * 3.2) * falloff;
  float mid  = exp(-r2 * 1.1) * falloff;

  vec3 col = u_cream;
  col = mix(col, u_amber, clamp(mid, 0.0, 1.0));
  col = mix(col, u_gold, clamp(core, 0.0, 1.0));

  /* micro-interaction 2: a small warm halo riding the cursor itself,
     distinct from the main pool — fades in and out with presence alone, so
     it never snaps on or off. */
  float dCursor = dot(P - ptr, P - ptr);
  float halo = exp(-dCursor * 9.0) * u_pointer.z;
  col = mix(col, vec3(1.0, 0.98, 0.9), halo * 0.4);
  col += u_gold * halo * 0.22;

  col = clamp(col, 0.0, 1.0);

  /* ---- the reading guard ---- */
  /* Pale ground, dark copy — this washes *toward* cream instead of
     clamping down, the inverse of the catalogue's dark fields. */
  vec2 rd = abs(uv - u_readA.xy) / max(u_readA.zw, vec2(0.02));
  float md = mix(max(rd.x, rd.y), length(rd), 0.4);
  float guardBand = 1.0 - smoothstep(0.72, 2.1, md);
  col = mix(col, mix(col, u_cream, 0.72), guardBand * u_guard);

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

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

export interface AmberGlowFieldProps {
  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 AmberGlowField({ className, guardSelector = null }: AmberGlowFieldProps) {
  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 OpalField/DuskBloomField: 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: 2_200_000,
      dprCap: 1.5,
    });
  }, [guardSelector]);

  return (
    <div
      className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
      aria-hidden="true"
    >
      <div className="iris-amberglowfield__floor absolute inset-0 [background:radial-gradient(_42%_40%_at_68%_54%,oklch(0.83_0.17_85)_0%,oklch(0.9_0.13_88)_24%,oklch(0.97_0.03_95)_46%,oklch(0.985_0.012_95)_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