Skip to content

IrisBackgroundsLiquid Shaft

Liquid Shaft

A hot-cored beam of light bending once and pouring wherever the cursor stands.

beam

Liquid Shaft

A shaft of light falls from an entry point above the top edge, bends once, and pours on down at an angle over a cool near-black ground — a hard red-orange body around a near-white core, bleeding softly into the dark around it. It is walked as a short chain of segments rather than one straight line, each swaying a little on a slow drift, and a brightness pulse runs down its length over time: light moving through the beam, not just sitting in it.

The point it pours onto is the cursor itself whenever one is present — move it and the whole lower run bends to keep landing there, elbow included, with a small warm pool gathering right where it lands. With no pointer it settles back to a slow drift near the lower-left.

  • warm
  • pointer-driven
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 { ShaftField } from "./ShaftField";

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">
      <ShaftField />
    </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 shaft of light falling through a near-black, cool-dark field —
 * a hard, hot-cored beam that drops from an entry point above the top edge,
 * bends once, and pours on down at an angle, the way a single doorway of
 * light bends across a floor. Warm red-orange body, a near-white core, a
 * soft ember bleed into the dark around it.
 *
 * The "liquid" is literal: the beam is walked as a short chain of
 * sub-segments rather than one straight line, each vertex swaying a little
 * on a slow low-frequency drift so the shaft ripples gently instead of
 * reading as a ruled line, and a brightness pulse runs down its length over
 * time — light moving through the beam, not just sitting in it.
 *
 * Interactive: the point the shaft pours onto IS the visitor's cursor,
 * whenever one is present — move the pointer and the whole lower run bends
 * to keep landing on it, elbow included, with a small warm pool gathering
 * right where it lands. No pointer, and it settles back to a slow
 * independent drift near the lower-left, so the field is never static.
 *
 * One of the reusable background fields (`SilkField`, `EmberField`,
 * `ArcLightsField`, …). 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-shaftfield__floor` underneath visible.
 *
 * Like `EmberField`, the palette is a fixed set passed as uniforms rather
 * than read from the accent ramp — this beam's red 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.014, 0.021, 0.026], // cool near-black behind everything
  u_deep: [0.16, 0.035, 0.02], // the beam's soft outer bleed into the dark
  u_beam: [0.95, 0.22, 0.11], // the body of the shaft
  u_core: [1.0, 0.86, 0.66], // 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_deep;
uniform vec3 u_beam;
uniform vec3 u_core;

uniform vec4  u_readA;
uniform float u_guard;

/* One bend, roughly 42% of the way down from the entry to the landing
   point — a short vertical fall, then the longer pour. Walked as SEG
   sub-segments per leg so the sway below has somewhere to ripple. */
const int   SEG        = 7;
const int   NPTS        = SEG * 2 + 1;
const float ELBOW_FRAC = 0.42;

/* Distance from p to segment ab; h is the clamped projection (0 at a .. 1 at
   b), carried out so the caller can tell how far along the whole path the
   nearest point sits. */
float distSegT(vec2 p, vec2 a, vec2 b, out float h) {
  vec2 pa = p - a, ba = b - a;
  h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0);
  return length(pa - ba * h);
}

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);        /* aspect, in height units */
  vec2 q   = uv * sc;

  /* The shaft's three anchors. The entry sways slowly on its own above the
     top edge; the landing point is the cursor whenever one is present, and
     a slow independent drift near the lower-left when it is not; the elbow
     falls straight down from the entry to a height between the two. */
  float topX = 0.60 + 0.05 * sin(u_time * 0.05);
  vec2  top  = vec2(topX, 1.10) * sc;

  vec2 landIdle = vec2(
    0.20 + 0.05 * sin(u_time * 0.037 + 1.7),
    -0.04 + 0.02 * sin(u_time * 0.061)
  );
  vec2 land = mix(landIdle, u_pointer.xy, u_pointer.z) * sc;

  vec2 elbow = vec2(top.x, mix(top.y, land.y, ELBOW_FRAC));

  /* Walk the two legs as a short chain, swaying each vertex a little in x
     on a slow low-frequency fbm — the wobble that reads as light in motion
     rather than a ruled line. The sway tapers to zero at both ends so the
     entry and the landing point stay exactly put. */
  vec2  prev   = top;
  float d      = 1e5;
  float bestT  = 0.0;
  for (int i = 1; i < NPTS; i++) {
    float tt = float(i) / float(NPTS - 1);
    vec2 base;
    if (i <= SEG) {
      base = mix(top, elbow, float(i) / float(SEG));
    } else {
      base = mix(elbow, land, float(i - SEG) / float(SEG));
    }
    float taper = smoothstep(0.0, 0.1, tt) * smoothstep(1.0, 0.9, tt);
    base.x += fbm(vec2(tt * 3.1 + 4.7, u_time * 0.10)) * 0.05 * taper;

    float h;
    float dd = distSegT(q, prev, base, h);
    if (dd < d) {
      d = dd;
      bestT = (float(i - 1) + h) / float(NPTS - 1);
    }
    prev = base;
  }

  /* Fuller at both ends of the fall — the source, and where it pours in —
     the way a poured liquid gathers at either end and runs thin between. */
  float widthK = 1.0
    + 0.9  * smoothstep(0.85, 1.0, bestT)
    + 0.35 * smoothstep(0.12, 0.0, bestT);
  float dK = d / widthK;

  /* A pulse travels down the shaft's length over time — the "liquid" in
     liquid light: it runs through the beam, not just sits in it. */
  float drip = pow(0.5 + 0.5 * sin(bestT * 5.0 * 6.2831853 - u_time * 0.6), 5.0);

  float core = exp(-dK * dK / (0.0035 * 0.0035));
  float body = exp(-dK * dK / (0.014  * 0.014));
  float glow = exp(-dK / 0.08);

  vec3 col = u_ground;
  col += u_deep * glow * 0.9;
  col += u_beam * body * (1.0 + drip * 0.5) * 1.5;
  col += u_core * core * (1.0 + drip * 0.4) * 1.3;

  /* Where it lands: a small pool of the same light, brightest with the
     cursor actually sitting in it — the one place the interaction reads
     unambiguously as "this beam is answering me". */
  vec2  toLand = q - land;
  float pool   = exp(-dot(toLand, toLand) / (0.05 * 0.05));
  col += u_beam * pool * 0.5;
  col += u_core * pool * pool * 0.4;

  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 ShaftFieldProps {
  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 ShaftField({ className, guardSelector = null }: ShaftFieldProps) {
  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 / EmberField: 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(_115deg,transparent_0%,transparent_45%,oklch(0.62_0.19_30_/_0.6)_48%,transparent_52%_),linear-gradient(_200deg,transparent_0%,transparent_56%,oklch(0.62_0.19_30_/_0.55)_59%,transparent_64%_),radial-gradient(_30%_20%_at_20%_88%,oklch(0.68_0.2_40_/_0.5)_0%,transparent_70%_),oklch(0.1_0.02_210)]" />
      <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