Skip to content

IrisBackgroundsRake Light

Rake Light

Diagonal flutes under a low raking light you move with the pointer.

rake

Rake Light

Thin ridges run upper-left to lower-right over a near-black warm ground, their crests catching a moving band of light whose colour walks along the ridge from rust through amber to chartreuse. Where the rake falls the flutes bloom wide and hot; away from it they crush to fine dark lines.

The pointer is the light source: move it and the whole rake slides across the flutes. With no pointer the rake drifts on its own, so a still frame still has a struck-light composition in it.

  • warm
  • raking-light
Family
rake
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <RakeField />
    </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 diagonal flutes under a low raking light — thin
 * ridges running upper-left to lower-right over a near-black warm ground,
 * their crests catching a moving band of light whose colour walks along the
 * ridge from rust through amber to chartreuse. Where the rake falls the
 * flutes bloom wide and hot; away from it they crush to fine dark lines.
 *
 * The pointer IS the light source: move it and the whole rake slides across
 * the flutes, following the cursor's position along the perpendicular axis.
 * With no pointer the rake drifts on its own, so a still frame still has a
 * struck-light composition in it rather than a flat texture.
 *
 * One of the reusable background fields (`TileField`, `SilkField`,
 * `AuroraVeil`, `SpineField`, `SlabField`). 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-rakefield__floor` underneath visible (a still
 * composed frame in the same palette, never a blank box).
 *
 * Like `AuroraVeil` and `SlabField` (and unlike `SilkField`), the palette is
 * a fixed warm set passed as uniforms rather than read from the token ramp —
 * the chartreuse crest lives nowhere in the accent ramp, so the CSS floor
 * carries the colour rather than a live token read.
 *
 * 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), so copy laid
 * over the field holds WCAG AA with no overlay layer. `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_black: [0.024, 0.017, 0.011],
  u_umber: [0.22, 0.093, 0.032],
  u_rust: [0.64, 0.17, 0.055],
  u_amber: [0.94, 0.46, 0.11],
  u_tan: [0.83, 0.63, 0.34],
  u_lime: [0.76, 0.86, 0.22],
  u_hot: [1.0, 0.86, 0.58],
};

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_black;   /* the warm near-black ground between the flutes */
uniform vec3 u_umber;   /* the deep shadow warmth and the ambient floor */
uniform vec3 u_rust;    /* the crest colour at one end of the flute */
uniform vec3 u_amber;   /* the crest colour mid-flute, and the hot corner */
uniform vec3 u_tan;     /* the khaki wash along the lit edge */
uniform vec3 u_lime;    /* the chartreuse crest at the far end of the flute */
uniform vec3 u_hot;     /* the near-white needle riding the centre of the rake */

/* Reading region for the contrast guard: xy = centre (uv, y up),
   zw = half-extent. Measured from the live copy block every frame.
   u_guard is 0 when the guard is off. */
uniform vec4  u_readA;
uniform float u_guard;

/* How many flutes cross the perpendicular axis. One line to retune density. */
const float RIDGES = 30.0;

void main() {
  vec2 res = u_res / u_scale;
  vec2 uv  = gl_FragCoord.xy / u_scale / res;          /* 0..1, y up */
  float aspect = res.x / res.y;
  vec2 P = (uv - 0.5) * vec2(aspect, 1.0);

  /* the flutes run upper-left to lower-right */
  vec2 dir  = normalize(vec2(1.0, -1.0));
  vec2 perp = vec2(-dir.y, dir.x);

  float along  = dot(P, dir);                          /* position along a flute */
  float across = dot(P, perp);                         /* which flute */

  /* the light source is a line parallel to the flutes; the pointer drives
     its offset along the perpendicular axis. With no pointer it drifts, so
     a frozen frame still shows a rake rather than a flat texture. */
  vec2 ptrP = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  float idle = sin(u_time * 0.11) * 0.30 + fbm(vec2(u_time * 0.04, 0.0)) * 0.12;
  float rake = mix(idle, dot(ptrP, perp), u_pointer.z);
  float d    = across - rake;
  float lit  = exp(-d * d * 5.5);                      /* bright where the rake falls */

  /* a slow domain warp bows the flutes like light bent across a ridged
     surface, then a crest that is wide and soft under the rake and a thin
     dark line away from it */
  float warp  = fbm(vec2(across * 1.3, along * 0.7 + u_time * 0.03)) * 0.4;
  float wave  = 0.5 + 0.5 * sin((across + warp) * RIDGES * 3.14159265);
  float crest = pow(wave, mix(7.0, 1.7, lit));

  /* the base field: a warm diagonal gradient, mostly dark, with a hot
     amber corner up and to the right */
  float g = smoothstep(0.85, -0.7, along);             /* 1 upper-left .. 0 lower-right */
  vec3 base = mix(u_black, u_umber, g * 0.7);
  vec2 corner = P - vec2(aspect * 0.34, 0.36);
  base = mix(base, u_amber * 0.6, exp(-dot(corner, corner) * 3.2) * 0.55);
  base += u_umber * 0.14;                              /* nothing is pure black */

  /* the crest colour walks along the flute: rust -> amber -> chartreuse */
  float h = 0.5 + 0.5 * sin(along * 1.6 + warp * 0.5 + u_time * 0.05);
  vec3 crestCol = mix(u_rust, u_amber, smoothstep(0.0, 0.6, h));
  crestCol = mix(crestCol, u_lime, smoothstep(0.55, 1.0, h));

  vec3 col = base;
  col += u_tan * lit * 0.18;                           /* khaki wash along the lit band */
  col += crestCol * crest * (0.22 + 1.7 * lit);
  col += u_hot * pow(wave, 40.0) * lit * 0.55;         /* the hot needle at the rake */

  /* the pointer also drops a soft radial bloom so the touch reads as a
     light being brought near, not just a band sliding */
  col += u_amber * u_pointer.z * exp(-dot(P - ptrP, P - ptrP) * 11.0) * 0.16;

  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 guardBand = 1.0 - smoothstep(0.72, 2.1, m);
  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 RakeFieldProps {
  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 RakeField({ className, guardSelector = null }: RakeFieldProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const guardOn = guardSelector != null;

    /* The block the reading guard protects. Looked up once, lazily. */
    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 / AuroraVeil: 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(_110%_90%_at_82%_12%,oklch(0.72_0.19_55)_0%,transparent_46%_),radial-gradient(_90%_85%_at_90%_90%,oklch(0.86_0.19_118)_0%,transparent_42%_),radial-gradient(_80%_95%_at_6%_40%,oklch(0.58_0.1_68)_0%,transparent_54%_),radial-gradient(_120%_120%_at_38%_32%,oklch(0.05_0.02_40)_0%,transparent_68%_),oklch(0.08_0.02_45)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-linear-gradient(_135deg,transparent_0_7px,oklch(0.03_0.01_40_/_0.72)_7px_12px_)]" />
      <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