Skip to content

IrisBackgroundsDusk Bloom

Dusk Bloom

A rich violet pool low on the left and an electric blue one high on the right, blended on their own diagonal over a ground that stays real black in the two corners off it.

glow

Dusk Bloom

Two soft colour pools sit on their own diagonal across an otherwise near-black frame — a rich violet low on the left, an electric blue high on the right, each with a brighter core near its own centre — blending into one wash where they meet and fading back to true black in the two corners off that diagonal, so the piece holds genuine dark space rather than a wall-to-wall gradient.

The pointer takes hold of both pools at once: move it and they drift a little further apart along their own axis, a small parallax rather than a snap, on top of a slow idle sway that keeps them from ever sitting perfectly still with nobody pointing at it.

  • cool
  • pointer-driven
  • glow
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 { DuskBloomField } from "./DuskBloomField";

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">
      <DuskBloomField />
    </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 two soft colour pools sitting on their own diagonal
 * over a near-black ground — a rich violet low on the left, an electric
 * blue high on the right, each with a brighter core near its centre —
 * blending into one wash where they meet and fading back to real black in
 * the two corners off that diagonal, so the piece holds genuine dark space
 * rather than a wall-to-wall gradient.
 *
 * The pointer takes hold of both pools at once: move it and they drift a
 * little further apart along their own axis, a small parallax rather than
 * a snap, on top of a slow idle sway that keeps them from ever sitting
 * perfectly still with nobody pointing at it.
 *
 * One of the reusable background fields (`AuroraVeil`, `OpalField`, …). 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-duskbloomfield__floor`
 * underneath visible — a still frame in the same palette, never a blank
 * box.
 *
 * Like `AuroraVeil`, the palette is a fixed violet/blue pair passed as
 * uniforms rather than the accent ramp — this mood lives nowhere in the
 * site's own 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 and saturation untouched). `null` (the
 * default) turns the guard off — for decorative use where nothing sits on
 * top.
 */

/* Palette, sRGB 0–1. Uniforms, not tokens — see the note above. */
const PALETTE: Record<string, [number, number, number]> = {
  u_ground: [0.006, 0.005, 0.014], // near-black, the two corners off the diagonal
  u_violet: [0.42, 0.07, 0.72], // the low-left pool
  u_blue: [0.11, 0.26, 0.98], // the high-right pool
};

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_ground;
uniform vec3 u_violet;
uniform vec3 u_blue;

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

  vec2 Pv = vec2(-aspect * 0.30, -0.16)
    + vec2(sin(u_time * 0.05) * 0.02, cos(u_time * 0.04) * 0.015)
    - (u_pointer.xy - 0.5) * vec2(aspect, 1.0) * 0.05 * u_pointer.z;
  vec2 Pb = vec2(aspect * 0.36, 0.30)
    + vec2(cos(u_time * 0.045) * 0.02, sin(u_time * 0.035) * 0.015)
    + (u_pointer.xy - 0.5) * vec2(aspect, 1.0) * 0.04 * u_pointer.z;

  vec2 dv = P - Pv;
  vec2 db = P - Pb;
  float distV = dot(dv, dv);
  float distB = dot(db, db);

  float glowV = exp(-distV * 1.7);
  float glowB = exp(-distB * 1.55);

  /* a tighter, brighter core inside each pool */
  float coreV = exp(-distV * 5.5);
  float coreB = exp(-distB * 5.0);

  vec3 col = u_ground;
  col += u_violet * glowV * 0.6;
  col += u_blue * glowB * 0.56;
  col += mix(u_violet, vec3(0.85, 0.72, 1.0), 0.4) * coreV * 0.22;
  col += mix(u_blue, vec3(0.75, 0.85, 1.0), 0.4) * coreB * 0.24;

  /* the two pools sit on their own diagonal; the corners off that diagonal
     (top-left, bottom-right) stay near-black, so the piece holds real dark
     space rather than a wall-to-wall wash */
  float dTL = length((uv - vec2(0.0, 1.0)) * vec2(aspect, 1.0));
  float dBR = length((uv - vec2(1.0, 0.0)) * vec2(aspect, 1.0));
  float vig = smoothstep(0.0, 1.05, min(dTL, dBR));
  col *= mix(0.32, 1.0, vig);

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

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

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

export interface DuskBloomFieldProps {
  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 DuskBloomField({ className, guardSelector = null }: DuskBloomFieldProps) {
  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 AuroraVeil / 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:radial-gradient(_58%_62%_at_16%_84%,oklch(0.4_0.19_305_/_0.85)_0%,transparent_68%_),radial-gradient(_62%_66%_at_86%_14%,oklch(0.5_0.2_258_/_0.85)_0%,transparent_68%_),oklch(0.03_0.006_280)] before:content-[''] before:absolute before:inset-0 before:[background:radial-gradient(_38%_42%_at_2%_100%,transparent_0%,oklch(0.02_0.004_280_/_0.9)_100%_),radial-gradient(_38%_42%_at_100%_0%,transparent_0%,oklch(0.02_0.004_280_/_0.9)_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