Skip to content

IrisBackgroundsSpillway

Spillway

Five flat colour ribbons gathered to a point, bowing out with a visible paper gap between each, to the bottom edge — a direct, flat-poster build of the reference photo.

flow

Spillway

Five flat ribbons — rust, orange, gold, teal, dark teal, left to right — gather to a point above centre and bow out to the bottom edge, a visible strip of paper between every one so each reads as its own ribbon rather than a shared wall of colour. Crisp edges, flat poster colour, no gloss or bloom: a direct build of the reference this was taken from, not a variation on the rest of the catalogue's glow.

A bright pulse is born at the gathering point on every ribbon and falls the length of it, fading in as it starts and out again at that ribbon's own end, each on its own phase. The pointer leans the whole fan toward its x, the way a poured stream drifts if you nudge the vessel it's falling from, and whichever ribbon the cursor sits nearest lightens at that height.

  • warm-cool
  • flat-color
  • pointer-driven
  • flow
Family
flow
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <SpillwayField />
    </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 road of five flat-coloured ribbons — rust, orange, gold,
 * teal, dark teal, left to right — running straight and parallel, tightly
 * packed with a visible paper gap between each, from the very top edge of
 * the frame down to one bend, then fanning at a constant angle the rest of
 * the way to the bottom edge. One elbow, not a slow bow the whole length
 * and not a point they all vanish into: the way a mountain road reads as a
 * straight run up top, a single turn where it rounds the shoulder, then a
 * straight run back down — `ConfluenceField`'s own two-segment ribbon
 * (fanned, then flattened) turned on its side, packed at the top instead of
 * the left edge. A direct build of a specific ribbon-waterfall reference
 * photo: flat poster colour and crisp edges, not the soft glow/gloss the
 * rest of this catalogue reaches for — the source this was built from has
 * neither, and copying it meant giving those up rather than carrying them
 * over out of habit. `CataractField` is the field that grew out of building
 * this — same engine, its own atmospheric, glowing, single-vertex direction
 * — kept as its own piece rather than overwritten here.
 *
 * A bright pulse is born at the top edge on every ribbon and travels down
 * its length, fading in as it starts and out again as it reaches the
 * ribbon's own end — the waterfall's motion, light riding the fall rather
 * than sitting on top of it. Each ribbon runs its own phase, so the frame
 * never reads as one synchronised drop.
 *
 * Interaction stays local to each ribbon: there is no whole-fan sway. Only
 * whichever ribbon the cursor actually sits over lightens at that height, a
 * hand held into that one strip of the fall — the rest hold still.
 *
 * 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-spillwayfield__floor`
 * underneath visible — a still frame in the same palette, never a blank box.
 *
 * Reading guard: when `guardSelector` resolves to an element, the field
 * measures that block every frame and lifts its own colour toward the paper
 * ground inside that region, the same move `ConfluenceField` makes for a
 * light ground. `null` (the default) turns the guard off.
 */

/* Palette, sRGB 0–1. Uniforms, not tokens — a warm paper ground and five
   flat ribbon colours have nowhere in the site's (dark-mode) ramp to live. */
const PALETTE: Record<string, [number, number, number]> = {
  u_paper: [0.961, 0.941, 0.859],
  u_rust: [0.82, 0.4, 0.27],
  u_orange: [0.89, 0.62, 0.27],
  u_gold: [0.89, 0.75, 0.29],
  u_teal: [0.28, 0.6, 0.54],
  u_deep: [0.14, 0.26, 0.32],
};

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_paper;
uniform vec3 u_rust;
uniform vec3 u_orange;
uniform vec3 u_gold;
uniform vec3 u_teal;
uniform vec3 u_deep;

uniform vec4  u_readA;
uniform float u_guard;

/* One ribbon: a two-segment path, exactly ConfluenceField's own fan/flat
   ribbon rotated onto the y axis. Below bendY it eases from bottomX (at
   y = 0) toward topX — steep near the bottom, flattening out by bendY.
   Above bendY the ease is already at 1, so x and thickness freeze at their
   topX/thickTop values and the ribbon just runs straight to the very top
   edge, uncropped by any fade — a mountain road's straight run at the top,
   not a point every ribbon vanishes into. Thickness eases the same way, so
   the ribbon is at its widest low down and holds a slimmer, constant width
   once it's packed in with the others. Flat colour, a crisp edge, no
   cross-section shading: this is a poster, not a glow. */
float ribbon(
  inout vec3 col,
  vec2 uv,
  float time,
  float bottomX,
  float thickBottom,
  float topX,
  float thickTop,
  float bendY,
  float endY,
  vec3 tint,
  float phase,
  vec3 paper,
  vec3 pointer
) {
  float t = clamp(uv.y / bendY, 0.0, 1.0);      /* 0 at y=0, 1 at bendY (and above) */
  float eased = t * (2.0 - t);                   /* steep low, flat by bendY */

  float x  = mix(bottomX, topX, eased);
  float thickness = mix(thickBottom, thickTop, eased);
  float halfW = thickness * 0.5;
  float edge = 0.0035;                           /* crisp, near-flat edge */

  float d = abs(uv.x - x);
  float body = 1.0 - smoothstep(halfW - edge, halfW + edge, d);

  /* alive down to its own staggered end, gone past that — no cutoff at the
     top, the ribbon runs off the frame's own edge */
  float alive = smoothstep(endY - 0.012, endY + 0.012, uv.y);

  float cov = body * alive;

  vec3 shaded = tint;

  /* the waterfall's motion: a slim glint born at the top edge, falling to
     this ribbon's own end. It lightens toward the ribbon's OWN hue, not
     toward the paper ground — mixing a cool tint straight toward a warm
     cream reads as a muddy grey smear, not a highlight, on flat colour with
     no gloss to carry it. Narrow, too: along a steeply angled ribbon even a
     modest Gaussian in uv.y projects into a long diagonal streak, so this
     stays tight enough to read as a shimmer crossing flat colour, not a
     gradient replacing it. */
  float cycle = fract(time * 0.16 + phase);
  float travelY = mix(1.0, endY, cycle);
  float distY = uv.y - travelY;
  float pulse = exp(-distY * distY * 3400.0);
  pulse *= smoothstep(0.0, 0.06, cycle) * (1.0 - smoothstep(0.88, 1.0, cycle));
  vec3 hot = mix(tint, vec3(1.0), 0.3);
  shaded = mix(shaded, hot, 0.35 * clamp(pulse, 0.0, 1.0));

  /* the cursor as a hand held into the fall, and only into THIS strip: check
     whether it is truly sitting on this ribbon at its own height (mirroring
     the geometry above, evaluated at pointer.y) rather than just near this
     ribbon's body at the current pixel's height — otherwise every ribbon
     crossing the cursor's height would light together. Nothing here moves
     the ribbon itself or any other strip; the interaction never leaves the
     one ribbon the cursor is actually over. */
  float tp = clamp(pointer.y / bendY, 0.0, 1.0);
  float xp = mix(bottomX, topX, tp * (2.0 - tp));
  float halfWp = mix(thickBottom, thickTop, tp) * 0.5;
  float alignX = exp(-pow((pointer.x - xp) / (halfWp * 1.1 + 0.015), 2.0));
  float hit = pointer.z * alignX;

  float atHeight = exp(-pow(uv.y - pointer.y, 2.0) / 0.014);
  float flare = hit * body * atHeight;
  vec3 flareHot = mix(tint, vec3(1.0), 0.5);
  shaded = mix(shaded, flareHot, 0.75 * flare);

  col = mix(col, shaded, clamp(cov, 0.0, 1.0));
  return cov;
}

void main() {
  vec2 res = u_res / u_scale;
  vec2 uv  = gl_FragCoord.xy / u_scale / res;   /* 0..1, y up, x left..right */

  vec3 col = u_paper;

  /* bottomX, thickBottom, topX, thickTop, bendY (staggered per ribbon — a
     soft cascade rather than one shared hinge, ConfluenceField's own
     move), staggered end. topX values sit close together but each
     distinct, ordered the same as bottomX so the ribbons never cross. */
  ribbon(col, uv, u_time, 0.06,  0.155, 0.365, 0.062, 0.58, 0.05, u_rust,   0.05, u_paper, u_pointer);
  ribbon(col, uv, u_time, 0.30,  0.135, 0.435, 0.054, 0.55, 0.02, u_orange, 0.34, u_paper, u_pointer);
  ribbon(col, uv, u_time, 0.515, 0.075, 0.51,  0.030, 0.52, 0.06, u_gold,   0.61, u_paper, u_pointer);
  ribbon(col, uv, u_time, 0.72,  0.14,  0.585, 0.056, 0.55, 0.03, u_teal,   0.83, u_paper, u_pointer);
  ribbon(col, uv, u_time, 0.97,  0.20,  0.68,  0.080, 0.58, 0.0,  u_deep,   0.19, u_paper, u_pointer);

  /* faint paper grain — a flat, near-white ground bands visibly across an
     eight-bit buffer without it */
  col += (bayer8(gl_FragCoord.xy) - 0.5) * (1.6 / 255.0);

  /* ---- the reading guard: lift toward paper, not down toward black ---- */
  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, u_paper, guardBand * u_guard * 0.85);

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

export interface SpillwayFieldProps {
  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 SpillwayField({
  className,
  guardSelector = null,
}: SpillwayFieldProps) {
  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 CataractField / ConfluenceField: 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: 1_800_000,
      dprCap: 1.5,
      alpha: false,
    });
  }, [guardSelector]);

  return (
    <div
      className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
      aria-hidden="true"
    >
      <div className="absolute inset-0 [background:conic-gradient(_from_205deg_at_48%_8%,oklch(0.58_0.15_35)_0deg,oklch(0.58_0.15_35)_26deg,oklch(0.73_0.15_62)_26deg,oklch(0.73_0.15_62)_44deg,oklch(0.81_0.14_88)_44deg,oklch(0.81_0.14_88)_56deg,oklch(0.6_0.1_178)_56deg,oklch(0.6_0.1_178)_76deg,oklch(0.32_0.05_222)_76deg,oklch(0.32_0.05_222)_112deg_),oklch(0.96_0.02_90)]" />
      <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