Skip to content

IrisBackgroundsIsobar

Isobar

A diagonal sweep of five vivid colour bands, violet to lime, each one landing on exactly a fifth of the diagonal instead of one hue dominating and the rest getting squeezed into the corners.

gradient

Isobar

Most gradients pick two or three colours and let one dominate — the rest end up as thin transition zones fighting for space. This one starts from the opposite constraint: five stops, evenly spaced, each occupying the same quarter of the diagonal, so violet, blue, teal, green and lime all get an equal say. The bands drift slowly along their own axis, and the colour stays clean — no added grain, just the ordered dither every field here carries so the smooth transitions never band.

The pointer leans a soft, wide warmth into whichever band it lands on rather than bending the bands themselves — this field is built to sit behind a photo and copy, so the composition has to hold still under a moving cursor.

  • gradient
  • diagonal
  • warm-to-cool
  • pointer-driven
Family
gradient
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <IsobarField />
    </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 diagonal sweep of five vivid colour bands — violet, blue,
 * teal, green, lime — each stop landing on exactly one fifth of the
 * diagonal, so no band reads as the "main" colour with the others squeezed
 * into the corners the way a two-stop `linear-gradient()` usually goes. The
 * bands themselves drift slowly along the diagonal rather than sitting
 * static. Clean colour, no added grain — just the ordered dither every
 * field here carries to stop an 8-bit buffer banding across the smooth
 * transitions.
 *
 * The pointer leans in rather than disturbing the sweep: presence lifts a
 * soft, wide bloom of extra light centred on the cursor, warming whichever
 * band it lands on without bending the bands themselves — this field is
 * built to sit behind a photograph and copy, so the composition needs to
 * stay legible under a moving cursor, not perform for it.
 *
 * 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-isobarfield__floor`
 * underneath visible — a still frame in the same five bands, never a blank
 * box.
 *
 * Fixed palette, not the token ramp — like `SpectrumGlassField` and
 * `SpectralLinenField`, this full sweep lives nowhere in the site's own
 * single-hue amber ramp.
 *
 * 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). `null` (the default) turns
 * the guard off — for decorative use where nothing sits on top.
 */

/* Palette, sRGB 0–1. Five stops, evenly spaced — see BANDS below. */
const PALETTE: Record<string, [number, number, number]> = {
  u_indigo: [0.302, 0.129, 0.596], // low corner of the diagonal — vivid violet, not near-black
  u_blue: [0.129, 0.365, 0.937],
  u_teal: [0.043, 0.667, 0.702],
  u_green: [0.180, 0.769, 0.298],
  u_lime: [0.784, 0.910, 0.196], // high corner of the diagonal
};

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_indigo;
uniform vec3 u_blue;
uniform vec3 u_teal;
uniform vec3 u_green;
uniform vec3 u_lime;

uniform vec4  u_readA;
uniform float u_guard;

/* Five stops at 0, .25, .5, .75, 1 — each of the four transitions gets the
   same ±0.11 half-width, so every band occupies an equal quarter of the
   diagonal rather than one hue dominating and the rest being squeezed into
   the corners the way an uneven CSS gradient usually goes. */
vec3 bands(float t) {
  vec3 col = mix(u_indigo, u_blue,  smoothstep(0.14, 0.36, t));
  col       = mix(col,     u_teal,  smoothstep(0.39, 0.61, t));
  col       = mix(col,     u_green, smoothstep(0.64, 0.86, t));
  col       = mix(col,     u_lime,  smoothstep(0.89, 1.00, t));
  return col;
}

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

  /* the diagonal axis: low-left (indigo) to high-right (lime), matching
     the reference's top-left-to-bottom-right sweep once y is flipped back
     to screen space */
  vec2 dir = normalize(vec2(1.0, 1.0));
  float t = dot(P, dir) / dot(vec2(aspect, 1.0), dir);

  /* a slow drift along the same axis — the sweep breathes rather than
     sitting static, without ever losing the equal-band structure */
  t += sin(u_time * 0.05) * 0.035;

  vec3 col = bands(clamp(t, -0.08, 1.08));

  /* the pointer leans a soft, wide warmth in rather than bending the
     bands — this field sits behind a photo and copy, so the composition
     itself must stay put */
  vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0) + 0.5 * vec2(aspect, 1.0);
  float dP = distance(P, ptr);
  float bloom = exp(-dP * dP * 1.1) * u_pointer.z;
  col += vec3(1.0) * bloom * 0.06;

  /* soft vignette toward the frame edges, same role as every other field */
  float vig = smoothstep(1.05, 0.4, distance(uv, vec2(0.5)));
  col *= mix(0.82, 1.0, vig);

  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.5), guardBand * u_guard);

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

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

const PALETTE_UNIFORMS = [
  "u_indigo",
  "u_blue",
  "u_teal",
  "u_green",
  "u_lime",
] as const;

export interface IsobarFieldProps {
  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 IsobarField({
  className,
  guardSelector = null,
}: IsobarFieldProps) {
  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: [...PALETTE_UNIFORMS, "u_readA", "u_guard"],
      onInit: (gl, u) => {
        for (const name of PALETTE_UNIFORMS) {
          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 SpectrumGlassField / OpalField: 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:linear-gradient(_135deg,oklch(0.45_0.24_302)_0%,oklch(0.45_0.24_302)_8%,oklch(0.55_0.24_264)_25%,oklch(0.55_0.24_264)_33%,oklch(0.68_0.15_195)_50%,oklch(0.68_0.15_195)_58%,oklch(0.72_0.2_145)_75%,oklch(0.72_0.2_145)_83%,oklch(0.88_0.2_120)_100%_)]" />
      <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