Skip to content

IrisBackgroundsRainline

Rainline

Night Road just after rain — the same trail bundle throws a rippled, dimmer copy of itself back up off a wet sheen, under a fine sheet of streaking rain and two out-of-focus sodium lamps.

trail

Rainline

A variation on `night-road`: the same perspective bundle of gold-to-amber light trails races toward a vanishing point, but the road itself is wet — below a waterline near the bottom edge, the scene is rendered a second time into a gently rippled mirror and blended back in at falling opacity, so the reflection narrows toward the same vanishing point rather than reading as a pasted-on flip. A fine sheet of rain streaks the whole frame, denser toward the camera, and two blurred sodium-lamp bokeh discs sit out of focus in the upper corners — the one light source that isn't a moving trail.

The pointer keeps `night-road`'s contract exactly: x steers the vanishing point, y throttles the flicker and dash travel the nearer it sits to the bottom edge, and the cursor drops the same warm headlight bloom.

  • warm
  • perspective
  • light-trails
Family
trail
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <RainlineField />
    </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 variation on `RoadField`'s night-road family: the same genuine
 * perspective projection racing a bundle of light trails toward a vanishing
 * point, but shot just after rain — the road itself throws a second, dimmer
 * copy of the trails back up at the camera off a wet sheen, and a fine sheet
 * of rain streaks the whole frame. Two blurred sodium-lamp bokeh discs sit
 * out of focus in the upper corners, the only light source that isn't a
 * moving trail.
 *
 * The reflection is the real scene function called a second time against a
 * mirrored, gently rippled `uv` below a waterline near the bottom edge, then
 * blended in at falling opacity with distance from that line — the same
 * `invZ`-driven projection as the real trails above it, so the reflected
 * bundle narrows toward the same vanishing point rather than reading as a
 * pasted-on flip.
 *
 * Pointer contract carried over from `RoadField` unchanged — steering and
 * throttle, not a new gesture:
 *  - steering — the pointer's x pans the vanishing point.
 *  - throttle — the pointer's y speeds the trail flicker and dash travel the
 *    nearer it sits to the bottom edge.
 * The same warm headlight bloom rides the cursor.
 *
 * One of the reusable background fields, a sibling of `RoadField` and
 * `ContraflowField` in the same trail family. 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-rainlinefield__floor` underneath visible (a still
 * composed frame in the same palette, never a blank box).
 *
 * Fixed palette, not the token ramp — same reasoning as `RoadField`: the
 * near-white flare core and the sodium-lamp bokeh live 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.012, 0.013, 0.017], // the wet-asphalt near-black, cooler than RoadField's
  u_wet: [0.11, 0.14, 0.17], // the cool blue-grey sheen the wet road catches
  u_gold: [0.95, 0.72, 0.3], // the main trail colour, gold rather than RoadField's red
  u_amber: [0.99, 0.5, 0.16], // the hotter outer trail
  u_flare: [1.0, 0.95, 0.85], // the near-white core and the rain-streak tint
  u_lamp: [0.92, 0.55, 0.2], // the blurred out-of-focus sodium streetlamp bokeh
  u_line: [0.85, 0.82, 0.74], // the dashed lane divider
};

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;
uniform vec3 u_wet;
uniform vec3 u_gold;
uniform vec3 u_amber;
uniform vec3 u_flare;
uniform vec3 u_lamp;
uniform vec3 u_line;

/* 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;

const float HORIZON = 0.66;  /* uv.y of the vanishing point */
const float VP_X    = -0.04; /* the point's x offset from centre, P-space */
const float ROAD_W  = 0.58;  /* road half-width at the camera, P-space */
const float MIN_BW  = 0.004; /* width floor so the bundle never divides by zero */
const float WATER   = 0.30;  /* uv.y of the waterline the reflection mirrors across */

float trail(float dx, float sigma, float wz, float seed, float spd, out float core) {
  float s2 = sigma * sigma + 1e-7;
  float body = exp(-dx * dx / s2);
  core = exp(-dx * dx / (s2 * 0.09));
  float flicker = 0.78 + 0.22 * sin(wz * 0.6 - u_time * spd + seed * 6.0);
  return body * flicker;
}

/* The scene, sans reflection, sans rain and sans the guard/dither — called
   twice from main(): once for the real frame, once mirrored below the
   waterline for the wet-road reflection, so the reflected bundle narrows
   toward the same vanishing point as the real one rather than reading as a
   pasted-on flip. */
vec3 scene(vec2 uv, float vpX, float speed) {
  float aspect = u_res.x / u_res.y;
  vec2 P = (uv - 0.5) * vec2(aspect, 1.0);

  float d = uv.y / HORIZON;
  float invZ = pow(clamp(1.0 - d, 0.0, 1.0), 1.3);
  float aboveHorizon = 1.0 - smoothstep(0.88, 1.02, d);
  float wz = (1.0 - invZ) / max(invZ, 0.02);

  float roadCenterX = vpX * (1.0 - invZ);
  float bw = max(ROAD_W * invZ, MIN_BW);

  float lane1 = roadCenterX - bw * 0.5;
  float lane2 = roadCenterX;
  float lane3 = roadCenterX + bw * 0.5;

  float c1, c2, c3;
  float e1 = trail(P.x - lane1, bw * 0.11, wz, 1.1, 0.85 * speed, c1);
  float e2 = trail(P.x - lane2, bw * 0.10, wz, 3.3, 1.0 * speed, c2);
  float e3 = trail(P.x - lane3, bw * 0.115, wz, 5.7, 0.9 * speed, c3);

  vec3 col = u_black;

  float haze = smoothstep(0.6, 0.0, d) * exp(-abs(P.x - roadCenterX) * 1.1);
  col += u_wet * haze * 0.14;

  col += u_gold * e1 * 1.5 * aboveHorizon;
  col += mix(u_gold, u_amber, 0.5) * e2 * 1.6 * aboveHorizon;
  col += u_amber * e3 * 1.5 * aboveHorizon;

  float hotCore = (c1 + c2 + c3) * aboveHorizon;
  col += u_flare * hotCore * 0.65;

  /* one dashed divider riding between the inner two lanes */
  float dashSigma = bw * 0.03 + 0.0011;
  float dxD = P.x - (roadCenterX - bw * 0.25);
  float lineBody = exp(-dxD * dxD / (dashSigma * dashSigma + 1e-7));
  float dp = fract(wz * 0.6 - u_time * 0.85 * speed);
  float dashOn = clamp(smoothstep(0.0, 0.05, dp) - smoothstep(0.55, 0.6, dp), 0.0, 1.0);
  col += u_line * lineBody * dashOn * aboveHorizon * 1.1;

  return max(col, 0.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;

  float steer = (u_pointer.x - 0.5) * u_pointer.z * 0.5;
  float speed = 1.0 + u_pointer.z * (1.0 - u_pointer.y) * 2.2;
  float vpX = VP_X + steer;

  vec3 col = scene(uv, vpX, speed);

  /* the wet sheen — a rippled, dimmed mirror of the same scene below a
     waterline near the bottom edge, the look of the trails throwing a second
     copy of themselves back up off rain-slicked asphalt */
  if (uv.y < WATER) {
    float ripple = sin(uv.x * 46.0 + u_time * 1.6) * 0.0035
                 + sin(uv.x * 17.0 - u_time * 0.8) * 0.005;
    vec2 ruv = vec2(uv.x + ripple, WATER + (WATER - uv.y) * 0.85);
    vec3 refl = scene(ruv, vpX, speed);
    /* fades in from 0 exactly at the waterline (so crossing it is seamless,
       not a hard step) before the usual exponential falls it off again with
       depth below the line */
    float fadeIn = smoothstep(WATER, WATER - 0.035, uv.y);
    float atten = exp(-(WATER - uv.y) * 3.4) * fadeIn;
    col = mix(col, col + refl * 0.6, atten);
    col += u_wet * 0.02 * atten;
  }

  /* a fine sheet of rain streaking down the whole frame — thin near-vertical
     lines, brighter and denser toward the camera */
  float streak = fract((uv.x + uv.y * 0.06) * 220.0 - u_time * 2.2);
  float rainLine = smoothstep(0.0, 0.02, streak) - smoothstep(0.05, 0.09, streak);
  float rainAmt = mix(0.03, 0.11, 1.0 - clamp(uv.y, 0.0, 1.0));
  col += u_flare * rainLine * rainAmt;

  /* two soft sodium bokeh discs, out-of-focus streetlamps up in the corners —
     the one light source in frame that isn't a moving trail */
  vec2 P = (uv - 0.5) * vec2(aspect, 1.0);
  vec2 lamp1 = vec2(-aspect * 0.42, 0.34);
  vec2 lamp2 = vec2(aspect * 0.46, 0.30);
  col += u_lamp * exp(-dot(P - lamp1, P - lamp1) * 9.0) * 0.5;
  col += u_lamp * exp(-dot(P - lamp2, P - lamp2) * 10.0) * 0.45;

  /* the touch: a warm headlight bloom with a near-white core, the same
     "your touch joined the light" move RoadField makes */
  vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  vec2 toPtr = P - ptr;
  float dPtr = dot(toPtr, toPtr);
  col += u_amber * u_pointer.z * exp(-dPtr * 9.0) * 0.5;
  col += u_flare * u_pointer.z * exp(-dPtr * 40.0) * 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 RainlineFieldProps {
  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 RainlineField({ className, guardSelector = null }: RainlineFieldProps) {
  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 RoadField / ContraflowField: 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(_34%_30%_at_22%_20%,oklch(0.7_0.1_60_/_0.5)_0%,transparent_62%_),radial-gradient(_30%_26%_at_80%_24%,oklch(0.68_0.11_55_/_0.45)_0%,transparent_60%_),radial-gradient(_36%_50%_at_50%_64%,oklch(0.92_0.08_85)_0%,transparent_10%_),radial-gradient(_46%_66%_at_50%_64%,oklch(0.78_0.16_65)_0%,transparent_30%_),radial-gradient(_70%_90%_at_50%_64%,oklch(0.5_0.16_45)_0%,transparent_55%_),radial-gradient(_55%_30%_at_50%_92%,oklch(0.3_0.1_55_/_0.4)_0%,transparent_70%_),radial-gradient(_100%_100%_at_50%_64%,oklch(0.15_0.03_220)_0%,transparent_78%_),oklch(0.028_0.012_220)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-linear-gradient(_12deg,transparent_0_3%,oklch(0.95_0.02_230_/_0.1)_3%_3.6%_)] after:[-webkit-mask-image:linear-gradient(_0deg,transparent_0_4%,#000_20%_90%,transparent_100%_)] after:[mask-image:linear-gradient(_0deg,transparent_0_4%,#000_20%_90%,transparent_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