Skip to content

IrisBackgroundsCloud Cascade

Cloud Cascade

A fall of blue-white liquid light dropping through drifting cloud, breaking wherever it meets something.

beam

Cloud Cascade

A column of light drops straight down through a bank of slow drifting blue-violet fog, over a near-black cool ground — a pale blue-white body around a hot near-white core, its edge swaying like a poured stream rather than running as a ruled line, with a brightness pulse travelling down its length. It always has somewhere to land: a fixed pool at the floor below, ringing outward on a slow cycle.

The cursor is a real object in its path. Move it into the column, anywhere along the fall's height, and the light breaks there — a bright pool and an outward ring at the true point of contact — while everything below turns from a hard core into a soft scattered veil, dimmer, the way light diffuses past whatever just stopped it. The floor pool dims to match, since less of the fall is getting through. Move the cursor clear and the break heals the whole way down.

  • cool
  • pointer-driven
  • volumetric
Family
beam
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <CascadeField />
    </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 fall of liquid light dropping straight down through a bank of
 * drifting blue-violet cloud, over a near-black cool ground — the shaft
 * itself a pale blue-white body around a hot near-white core, its edge
 * swaying on a slow low-frequency drift rather than running as a ruled
 * line, with a brightness pulse travelling down its length: light moving
 * through the fall, not just sitting in it.
 *
 * It always has somewhere to land — a fixed pool at the floor it is falling
 * toward, ringing outward on a slow cycle — and it leans, whole column and
 * all, to keep landing wherever the cursor stands.
 *
 * The collision is literal: the cursor is an object the fall can hit
 * anywhere along its height, not only at the floor. Whenever the cursor
 * actually sits inside the column, the light breaks there — a bright pool
 * and an outward ring at the true point of contact — and everything below
 * that height turns from a hard core into a soft scattered veil, dimmer,
 * the way light diffuses past whatever just stopped it. The floor pool dims
 * to match, since less of the fall is reaching it. Move the cursor out of
 * the column and the break heals, the whole way down.
 *
 * One of the reusable background fields (`SilkField`, `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-cascadefield__floor`
 * underneath visible.
 *
 * Like `ShaftField` and `EmberField`, the palette is a fixed set passed as
 * uniforms rather than read from the accent ramp — this fall's cool
 * blue-violet is its own mood, not the portfolio's amber.
 *
 * 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.012, 0.014, 0.026], // near-black, cool blue-violet
  u_cloudA: [0.10, 0.16, 0.34], // the drifting fog, lower/cooler
  u_cloudB: [0.22, 0.14, 0.40], // the drifting fog, upper/violet
  u_beam: [0.55, 0.68, 0.98], // the body of the fall
  u_core: [0.94, 0.97, 1.0], // the hot near-white line riding its centre
};

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_cloudA;
uniform vec3 u_cloudB;
uniform vec3 u_beam;
uniform vec3 u_core;

uniform vec4  u_readA;
uniform float u_guard;

/* Where the fall meets the floor it is dropping toward — fixed, so it
   always has somewhere real to land even with no pointer at all. */
const float GROUND_Y = 0.18;

void main() {
  vec2 res = u_res / u_scale;
  vec2 uv  = gl_FragCoord.xy / u_scale / res;           /* 0..1, y up */
  vec2 sc  = vec2(res.x / max(res.y, 1.0), 1.0);
  vec2 q   = uv * sc;

  /* The column's centre x: a slow independent drift when no one is
     pointing at it, eased toward the cursor's x when there is one — the
     whole fall leans to keep landing under the visitor. */
  float idleX = 0.5 + 0.05 * sin(u_time * 0.045);
  float colX  = mix(idleX, u_pointer.x, u_pointer.z);

  /* Liquid, not a ruled line: a low-frequency sway riding the column's
     whole height. Sampled again below at the cursor's own height, so a
     collision reads at the true swayed position rather than the straight
     one. */
  float sway = fbm(vec2(uv.y * 2.1 + 1.3, u_time * 0.06)) * 0.02;
  float cx   = (colX + sway) * sc.x;
  float dx   = q.x - cx;

  /* Fuller where it meets the ground and at the very top, thin in between
     — the way a poured stream gathers at both ends. */
  float widthK = 1.0
    + 0.85 * smoothstep(GROUND_Y + 0.10, GROUND_Y, uv.y)
    + 0.30 * smoothstep(0.86, 1.0, uv.y);
  float dK = dx / widthK;

  /* A brightness pulse runs down the column over time. */
  float drip = pow(0.5 + 0.5 * sin(uv.y * 5.0 * 6.2831853 - u_time * 0.75), 5.0);

  /* ---- the collision: the cursor is an object the fall can hit. hit is
     how directly it sits inside the column at ITS OWN height — not the
     current pixel's — so the break happens at one true height. The 0.02
     sway is dropped here (not re-sampled at the pointer's height): it is
     well inside the alignment tolerance below, and every pixel on screen
     was paying for an fbm() call whose result never varied across the
     frame — a uniform-only value, recomputed per-pixel for nothing. */
  float cxAtP = colX * sc.x;
  float align = exp(-pow((u_pointer.x * sc.x - cxAtP) / 0.045, 2.0));
  float hit   = u_pointer.z * align;

  /* Below the collision height the fall scatters rather than carries
     through — dimmer core, a soft veil instead of a hard line. Above it,
     nothing changes. */
  float scatter = hit * smoothstep(u_pointer.y + 0.02, u_pointer.y - 0.16, uv.y);

  float core = exp(-dK * dK / (0.0032 * 0.0032)) * mix(1.0, 0.18, scatter);
  float body = exp(-dK * dK / (0.013  * 0.013))  * mix(1.0, 0.42, scatter);
  float glow = exp(-abs(dK) / 0.075);
  float veil = scatter * exp(-dK * dK / (0.05 * 0.05)) * 0.5;

  /* ---- the clouds: slow drifting fog with real wisps in it, denser near
     the fall than at the edges, cool blue low, violet high — the bank it
     pours out of. The domain warp only needs to displace, not resolve fine
     detail, so it rides single-octave vnoise (four hashes) rather than a
     full fbm() (sixteen) — the fbm below is where the actual wisp detail
     comes from. */
  vec2 warpA = vec2(vnoise(q * 0.9 + vec2(3.1, -u_time * 0.02)), vnoise(q * 0.9 + vec2(-7.4, u_time * 0.017)));
  vec2 cp = q * vec2(1.5, 1.05) + warpA * 0.6 + vec2(0.0, -u_time * 0.02);
  float n = fbm(cp * 1.15) * 0.65 + fbm(cp * 2.9 + 5.1) * 0.35;
  float density  = smoothstep(0.02, 0.5, n);
  float nearFall = exp(-abs(dx) / 0.7);
  float cloudDensity = density * (0.32 + 0.85 * nearFall) + hit * exp(-abs(dx) / 0.5) * 0.3;
  vec3  cloudCol = mix(u_cloudA, u_cloudB, clamp(n * 0.5 + uv.y * 0.55 + 0.1, 0.0, 1.0));

  /* ---- where it lands: the ground, always there, and the cursor,
     whenever it truly sits inside the column. Both pool the light and both
     ring outward — but the ground's pool dims when the fall is being
     caught higher up, the way less reaches a drain once something is held
     under the tap. */
  vec2  toGround   = q - vec2(colX, GROUND_Y) * sc;
  float groundPool = exp(-dot(toGround, toGround) / (0.11 * 0.11));
  float groundT    = fract(u_time * 0.12);
  float groundRing = exp(-pow(length(toGround) - groundT * 0.55, 2.0) / 0.0022) * (1.0 - groundT);
  float groundK    = mix(1.0, 0.28, hit);

  vec2  toHit  = q - u_pointer.xy * sc;
  float hitPool = hit * exp(-dot(toHit, toHit) / (0.028 * 0.028));
  float hitT    = fract(u_time * 0.5);
  float hitRing = hit * exp(-pow(length(toHit) - hitT * 0.16, 2.0) / 0.0008) * (1.0 - hitT);

  vec3 col = u_ground;
  col += cloudCol * cloudDensity;
  col += u_beam * glow * 0.85;
  col += u_beam * body * (1.0 + drip * 0.4);
  col += u_core * core * (1.0 + drip * 0.35);
  col += u_beam * veil;
  col += u_beam * groundPool * 0.55 * groundK;
  col += u_core * groundPool * groundPool * 0.3 * groundK;
  col += u_core * groundRing * 0.5 * groundK;
  col += u_core * hitPool * 1.1;
  col += u_beam * hitRing * 0.7;

  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 m = mix(max(rd.x, rd.y), length(rd), 0.4);
  float band = 1.0 - smoothstep(0.72, 2.1, m);
  col = mix(col, holdUnder(col, 0.09), band * u_guard);

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

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

export interface CascadeFieldProps {
  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 CascadeField({ className, guardSelector = null }: CascadeFieldProps) {
  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 SilkField / ShaftField: 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(_180deg,transparent_0%,transparent_88%,oklch(0.86_0.05_250_/_0.7)_91%,transparent_95%_),linear-gradient(_90deg,transparent_0%,transparent_47%,oklch(0.9_0.03_250_/_0.85)_49%,transparent_51%_),radial-gradient(_26%_16%_at_50%_18%,oklch(0.72_0.09_250_/_0.6)_0%,transparent_70%_),radial-gradient(_60%_46%_at_18%_30%,oklch(0.28_0.1_280_/_0.55)_0%,transparent_70%_),radial-gradient(_55%_42%_at_84%_68%,oklch(0.24_0.09_255_/_0.5)_0%,transparent_70%_),oklch(0.06_0.015_265)]" />
      <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