Skip to content

IrisBackgroundsNimbus

Nimbus

Four named colour fields — violet, blue, cyan, mint — placed once and mirrored across the centre, so violet holds only the top corners and a broad mint mass owns the centre-low.

glow

Nimbus

Where `isobar` proves a gradient can hold several colours in equal measure, this one proves the opposite is just as deliberate: violet is concentrated tightly at the two top corners, blue carries the full side edges top to bottom (so the bottom corners land on blue, never violet), and mint sits as one broad, horizontally-stretched mass low and centred — cyan has no position of its own, appearing only where blue and mint overlap. Every field is a soft Gaussian falloff blended by weight rather than a nested ring, so nothing reads as a visible circle.

No pointer interaction — each field drifts on its own small, slow, out-of-phase ellipse instead, amplitude tuned to a few pixels so the motion reads as a held breath, never a pulse.

  • gradient
  • multi-field
  • cool-to-warm
  • static
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 { GradientBackground } from "./GradientBackground";

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">
      <GradientBackground />
    </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 colour field built from four named stops — deep violet,
 * blue, cyan and mint — placed as soft Gaussian-falloff regions and blended
 * by normalised weight rather than sequential smoothstep rings, so nothing
 * ever reads as a visible circle: violet sits only at the two top corners,
 * blue carries the side edges top to bottom (so the bottom corners land on
 * blue, never violet), and mint is one broad, horizontally-stretched mass
 * low-and-centre. Cyan has no position of its own — it appears wherever the
 * blue and mint fields overlap, standing in for the transition ring between
 * them.
 *
 * No pointer interaction, no procedural noise, no blur/backdrop-filter, no
 * bloom, no vignette. The only per-pixel add is the same ordered dither
 * every field in this catalogue carries — not texture, just what stops an
 * 8-bit buffer banding across a gradient this broad and slow.
 *
 * Static by default in spirit: `speed` defaults to a barely-there 0.03, and
 * even at that speed the drift amplitude is a few pixels' worth — no pulse,
 * no visible motion, just a held breath. Set `speed={0}` for a fully frozen
 * frame.
 *
 * Drop it into any `position: relative`/`isolate` parent — it fills the
 * box (`position: absolute; inset: 0; pointer-events: none`). 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-gradientbackground__floor` underneath visible —
 * a still frame in the same four stops, never a blank box.
 *
 * 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 untouched) so light-coloured copy laid
 * over it holds contrast — this field runs bright enough at its mint core
 * that copy needs it. `null` (the default) turns the guard off.
 *
 * ---- Tuning guide ----
 * Every prop below maps straight to a shader uniform; none of it requires
 * touching the GLSL:
 *   `purple` / `blue` / `cyan` / `mint`   — the four colour stops (sRGB 0–1).
 *   `purplePosition` / `bluePosition`     — where each field sits, given
 *                                           once and mirrored across the
 *                                           centre line automatically (so
 *                                           moving one moves both corners
 *                                           at once). `x` is a FRACTION of
 *                                           the half-width — 1.0 is the
 *                                           true screen edge on any aspect
 *                                           ratio, so ~0.9 sits right in
 *                                           the corner on a phone and an
 *                                           ultrawide alike. `y` is literal
 *                                           (screen half-height is always
 *                                           ±0.5): positive is toward the
 *                                           top.
 *   `mintPosition`                        — the mint mass's own centre, in
 *                                           literal units; leave x at 0 to
 *                                           keep it on the midline, move y
 *                                           negative to push it lower.
 *   `mintScale`                           — mint's ellipse radius. `x` is
 *                                           a fraction of the half-width
 *                                           (same aspect-safe reasoning as
 *                                           the positions above); `y` is
 *                                           literal. Larger x vs. y is what
 *                                           makes it read as a wide band
 *                                           rather than a spotlight; the
 *                                           reference wants x roughly 3× y.
 *   `speed`                               — drift speed; 0 freezes it.
 */

const DEFAULTS = {
  purple: [0.208, 0.063, 0.435] as [number, number, number], // #35106F
  blue: [0.204, 0.494, 0.859] as [number, number, number], // #347EDB
  cyan: [0.196, 0.78, 0.816] as [number, number, number], // #32C7D0
  mint: [0.545, 0.937, 0.647] as [number, number, number], // #8BEFA5
  purplePosition: [0.88, 0.4] as [number, number],
  bluePosition: [0.94, -0.05] as [number, number],
  mintPosition: [0, -0.1] as [number, number],
  mintScale: [0.85, 0.3] as [number, number],
  speed: 0.03,
};

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

uniform vec3  u_purple;
uniform vec3  u_blue;
uniform vec3  u_cyan;
uniform vec3  u_mint;

uniform vec2  u_purplePos;
uniform vec2  u_bluePos;
uniform vec2  u_mintPos;
uniform vec2  u_mintScale;
uniform float u_speed;

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

  /* Each field drifts on its own small, slow ellipse, out of phase with the
     others so nothing pulses in unison — amplitude stays tiny (a few
     pixels' worth on a real screen) regardless of speed. */
  float t = u_time * u_speed;
  vec2 driftA = vec2(cos(t * 0.9),        sin(t * 0.7))        * 0.018;
  vec2 driftB = vec2(cos(t * 0.6 + 2.1),  sin(t * 0.8 + 1.4))  * 0.02;
  vec2 driftC = vec2(sin(t * 0.5 + 4.0),  cos(t * 0.4 + 0.6))  * 0.014;

  /* Purple and blue are defined once and mirrored across the centre line —
     one position uniform lights both corners/edges at once. Their x is
     given as a FRACTION of the half-width (0..~1, 1 = the true screen
     edge) rather than a raw P-space unit, so a position tuned on a square
     viewport still sits at the same relative spot on an ultrawide one —
     a fixed P-space x would land short of the real corner as aspect grows. */
  vec2 Pm = vec2(abs(P.x), P.y);
  float halfW = aspect * 0.5;

  vec2 purpleC = vec2(u_purplePos.x * halfW, u_purplePos.y) + driftA;
  vec2 blueC   = vec2(u_bluePos.x   * halfW, u_bluePos.y)   + driftB;
  vec2 mintC   = u_mintPos + driftC;

  /* Gaussian falloff per field — soft everywhere, never a hard edge, so
     overlapping regions blend rather than showing a seam. Spread is also
     given as a fraction of half-width on x, for the same aspect reason. */
  vec2 dp = (Pm - purpleC) / vec2(halfW * 0.34, 0.3);
  float wPurple = exp(-dot(dp, dp)) * 1.8; /* wins the corner outright over blue */

  vec2 db = (Pm - blueC) / vec2(halfW * 0.46, 0.82);
  float wBlue = exp(-dot(db, db) * 0.85);

  vec2 dm = (P - mintC) / vec2(halfW * u_mintScale.x, max(u_mintScale.y, 0.05));
  float wMint = exp(-dot(dm, dm));

  /* Cyan has no position of its own: it is what shows where blue and mint
     are both present in moderate amount, standing in for the transition
     ring between them without needing a third centre to tune. */
  float wCyan = wBlue * wMint * 3.4;

  float wSum = wPurple + wBlue + wMint + wCyan + 1e-4;
  vec3 col = (u_purple * wPurple + u_blue * wBlue + u_mint * wMint + u_cyan * wCyan) / wSum;

  col = max(col, 0.0);

  /* ---- the reading guard ---- */
  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, holdUnder(col, 0.16), guardBand * u_guard);

  /* Ordered dither only — see the file doc for why this isn't "grain". */
  col += (bayer8(gl_FragCoord.xy) - 0.5) * (1.6 / 255.0);

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

export interface GradientBackgroundProps {
  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;
  purple?: [number, number, number];
  blue?: [number, number, number];
  cyan?: [number, number, number];
  mint?: [number, number, number];
  purplePosition?: [number, number];
  bluePosition?: [number, number];
  mintPosition?: [number, number];
  mintScale?: [number, number];
  /** Drift speed. 0.03 by default — almost imperceptible. 0 freezes it. */
  speed?: number;
}

export function GradientBackground({
  className,
  guardSelector = null,
  purple = DEFAULTS.purple,
  blue = DEFAULTS.blue,
  cyan = DEFAULTS.cyan,
  mint = DEFAULTS.mint,
  purplePosition = DEFAULTS.purplePosition,
  bluePosition = DEFAULTS.bluePosition,
  mintPosition = DEFAULTS.mintPosition,
  mintScale = DEFAULTS.mintScale,
  speed = DEFAULTS.speed,
}: GradientBackgroundProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  const values = { purple, blue, cyan, mint, purplePosition, bluePosition, mintPosition, mintScale, speed };
  const valuesRef = useRef(values);
  valuesRef.current = values;

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

    const UNIFORM_NAMES = [
      "u_purple",
      "u_blue",
      "u_cyan",
      "u_mint",
      "u_purplePos",
      "u_bluePos",
      "u_mintPos",
      "u_mintScale",
      "u_speed",
      "u_readA",
      "u_guard",
    ] as const;

    const applyValues = (gl: WebGLRenderingContext, u: Record<string, WebGLUniformLocation | null>) => {
      const v = valuesRef.current;
      if (u.u_purple) gl.uniform3fv(u.u_purple, v.purple);
      if (u.u_blue) gl.uniform3fv(u.u_blue, v.blue);
      if (u.u_cyan) gl.uniform3fv(u.u_cyan, v.cyan);
      if (u.u_mint) gl.uniform3fv(u.u_mint, v.mint);
      if (u.u_purplePos) gl.uniform2fv(u.u_purplePos, v.purplePosition);
      if (u.u_bluePos) gl.uniform2fv(u.u_bluePos, v.bluePosition);
      if (u.u_mintPos) gl.uniform2fv(u.u_mintPos, v.mintPosition);
      if (u.u_mintScale) gl.uniform2fv(u.u_mintScale, v.mintScale);
      if (u.u_speed) gl.uniform1f(u.u_speed, v.speed);
    };

    return mountShaderSurface(canvas, {
      fragment: FRAG,
      uniforms: [...UNIFORM_NAMES],
      onInit: (gl, u) => {
        applyValues(gl, u);
        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) => {
        applyValues(gl, u);
        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"),
      onLost: () => canvas.removeAttribute("data-shader"),
      maxPixels: 2_200_000,
      dprCap: 1.5,
    });
  }, [guardSelector]);

  return (
    <div
      className={`iris-gradientbackground${className ? ` ${className}` : ""}`}
      aria-hidden="true"
    >
      <div className="iris-gradientbackground__floor" />
      <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