Skip to content

IrisBackgroundsSpot Gel

Spot Gel

A theatrical gel of fine vertical stripes lit from within — rust rim, gold ring, near-white core, black past the edge.

spotlight

Spot Gel

One held circle of light on a pure black ground: fine vertical gel stripes running the full spot, coloured by a radial ramp underneath them — near-white at the centre, out through gold and amber to a rust rim, feathered to black past the edge. The stripes are fixed to the x-axis alone rather than fanning from the centre, so the piece reads as a printed gel held in front of a lamp, not rays radiating from a point.

The lamp leans a few percent toward the cursor, eased, the way a held light drifts toward a hand near it; with no pointer it idles on a slow, barely-there orbit so a frozen frame still reads as a struck light rather than a flat target. Fixed warm palette, not the token ramp — the rust-to-gold run lives nowhere in the accent ramp, so the CSS floor carries the colour.

  • warm
  • spotlight
  • pointer-driven
Family
spotlight
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <SpotGelField />
    </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 spotlight, built to one photographed reference: a circle of
 * fine vertical gel stripes on a pure black ground, its hue walking between
 * a saturated orange and a saturated yellow-green (never blue — every
 * stripe's blue channel sits at zero) as it crosses the beam, brightness
 * falling off radially to black at the rim, and a near-white core taking
 * over the middle where the stripes wash out under direct light.
 *
 * The stripes are a fixed spatial frequency along x only (not radial
 * spokes), so the banding reads as a flat, printed gel sitting in front of
 * the light rather than rays fanning from a point — the radial falloff and
 * the white core are what actually carry the spotlight's shape; the stripes
 * are a texture riding on top of it.
 *
 * One of the reusable background fields (`RakeField`, `SolarFanField`,
 * `ShaftField`). 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-spotgelfield__floor`
 * underneath visible (a still composed frame in the same palette, never a
 * blank box).
 *
 * Fixed warm/green palette, not the token ramp — the gel's orange-to-
 * chartreuse run lives nowhere in the accent ramp, so the CSS floor carries
 * the colour rather than a live token read (same contract as `RakeField` /
 * `AuroraVeil`).
 *
 * The pointer leans the spot a few percent toward itself, eased, the way a
 * held lamp drifts toward a hand near it. With no pointer the spot idles on
 * a slow, barely-there drift so a frozen frame still reads as a struck light
 * rather than a flat printed circle.
 *
 * 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), so copy laid
 * over the field holds WCAG AA with no overlay layer. `null` (the default)
 * turns the guard off — for decorative use where nothing sits on top.
 */

/* Palette, sRGB 0–1, read straight off the reference photograph's pixels
   (sampled per-radius and per-stripe-phase, not eyeballed). See the note
   above — no blue in the stripe hue at any radius. */
const PALETTE: Record<string, [number, number, number]> = {
  u_black: [0.0, 0.0, 0.0],
  u_orange: [0.81, 0.56, 0.02],
  u_chartreuse: [0.84, 0.91, 0.04],
  u_core: [0.9, 0.91, 0.87],
};

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_black;       /* past the rim, and what the radial falloff darkens toward */
uniform vec3 u_orange;      /* one end of the stripe hue swing */
uniform vec3 u_chartreuse;  /* the other end of the stripe hue swing */
uniform vec3 u_core;        /* the near-white centre the stripes wash out into */

/* Reading region for the contrast guard: xy = centre (uv, y up),
   zw = half-extent. Measured from the live copy block every frame.
   u_guard is 0 when the guard is off. */
uniform vec4  u_readA;
uniform float u_guard;

/* How many gel stripes cross the spot's width — fit to the reference's own
   stripe pitch (about 1/22 of the frame width), not a round number. */
const float STRIPES = 16.3;

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

  /* the lamp leans a few percent toward the pointer, eased by its own
     presence so it settles back to dead centre with nothing near it; with
     no pointer it idles on a slow orbit so a frozen frame is never a
     perfectly static, flat-printed circle */
  vec2 idleC = vec2(sin(u_time * 0.055), cos(u_time * 0.047)) * 0.018;
  vec2 ptrP  = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  vec2 center = mix(idleC, ptrP * 0.06, u_pointer.z);
  vec2 Q = P - center;

  /* r is in units of frame-height, matching the reference photo's own
     radius so the fitted constants below (0.375 / 0.58 / the core's 15.0)
     carry over exactly regardless of the box's own aspect ratio. */
  float r = length(Q);

  /* fine vertical gel stripes, fixed to x alone so they sit in front of the
     light rather than fanning from its centre; a slow phase drift keeps a
     still frame from reading as a static printed texture. The stripe hue
     itself swings between orange and chartreuse at full saturation — no
     radial hue change, exactly what the reference does. */
  float phase = Q.x * STRIPES + sin(u_time * 0.03) * 0.4;
  float wave  = 0.5 + 0.5 * sin(phase * 6.28318);
  vec3 stripe = mix(u_orange, u_chartreuse, wave);

  /* radial brightness: full strength out to r=0.375 (frame-height units),
     falling to black by r=0.58 — fitted to the reference's own falloff,
     which is a circle notably larger than the frame (so the corners go
     black while the top/bottom centre stays lit). */
  float bright = 1.0 - smoothstep(0.375, 0.58, r);
  vec3 col = stripe * bright;

  /* the near-white core the stripes wash out into at the lamp's centre —
     a mix toward u_core, not an add, so it desaturates rather than blows
     out to a colour the reference never shows */
  float core = exp(-r * r * 15.0);
  col = mix(col, u_core, core);

  col = max(col, 0.0);

  /* ---- the reading guard (see RakeField 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 guardBand = 1.0 - smoothstep(0.72, 2.1, m);
  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 SpotGelFieldProps {
  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 SpotGelField({ className, guardSelector = null }: SpotGelFieldProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const guardOn = guardSelector != null;

    /* The block the reading guard protects. Looked up once, lazily. */
    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 RakeField / SilkField: 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="absolute inset-0 [background:radial-gradient(_62%_62%_at_50%_50%,oklch(0.93_0.02_95)_0%,oklch(0.87_0.19_110)_30%,oklch(0.75_0.19_90)_55%,oklch(0.5_0.15_65)_76%,oklch(0_0_0)_92%_),oklch(0_0_0)]" />
      <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