Skip to content

IrisBackgroundsIgnis

Ignis

A wide bundle of crimson-to-gold light trails sweeping in from both bottom corners, bending through one shared S before collapsing into a white-hot flare that leans toward the pointer.

trail

Ignis

Built to one specific long-exposure reference photo: dozens of strands sweep in from far outside both the left and right edges along the bottom of the frame, each converging on its own timing toward one flare sitting right of centre and a little above the middle — some bend in early, some stay wide almost to the end — but every one of them rides the same shared lateral bulge, which is what makes the whole bundle read as a single sweeping S rather than a straight fan of separate lines. Colour runs off distance from the flare rather than off any fixed lane: deep crimson along the bottom edge, warming through amber to gold as a strand climbs, and only turning near-white in the last stretch, right where it feeds into the flare's own soft bloom, tight hot core, and thin anamorphic streak.

The flare leans a few percent of the frame toward wherever the pointer sits, dragging the whole convergent bundle with it since every strand answers to the same point, and the cursor drops its own soft ember bloom.

  • warm
  • pointer-driven
  • light-trails
  • perspective
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 { IgnisField } from "./IgnisField";

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">
      <IgnisField />
    </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 build to one specific reference photo: a long-exposure fan of
 * light trails sweeping in off both the lower-left and lower-right, bending
 * through one shared "S" — the whole bundle leans one way then the other as
 * it climbs — before it collapses into a single white-hot flare sitting
 * right of centre and a little above the middle of the frame. Every strand
 * is deep crimson where it's furthest from that point (nearest the camera,
 * along the bottom edge), warms through amber to gold as it travels, and
 * only goes near-white in the last stretch right at the flare itself.
 *
 * Each strand is a curve, not a straight lane: `t` runs from 0 at the
 * bottom edge to 1 at the flare's own height, and a strand's x position is
 * its own per-strand convergence toward the flare (`mix(startX, VP_X,
 * ease)`, `ease`'s exponent hashed per strand so some bend early and some
 * late) plus one shared `sin(t * PI)` lateral bulge every strand rides
 * together — the term that reads as the whole bundle sweeping through one
 * S rather than each strand just drawing a straight line to the same
 * point. `startX` is drawn from far outside both edges, which is why the
 * bundle reads as wide and unbounded along the bottom rather than a fan
 * with visible end lanes.
 *
 * Unlike `TorrentField` / `PalisadeField`, this one does answer to the
 * cursor, kept to two small, literal moves: the flare itself — and every
 * strand converging on it — drifts a few percent of the frame toward
 * wherever the pointer sits, the way a light source leans when you nudge
 * it, and the cursor drops its own soft ember bloom, the same "your touch
 * joined the light" move `RoadField` / `RakeField` make with theirs. The
 * thin anamorphic streak riding through the flare is anchored separately,
 * at the flare's resting position rather than its pointer-driven one, so
 * that one line holds still while everything else leans.
 *
 * One of the reusable background fields. 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-ignisfield__floor` underneath visible.
 *
 * 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. `null` (the default) turns the guard off.
 */

/* Palette, sRGB 0–1. Uniforms, not tokens — this fixed crimson/amber/gold
   mood, built to the one reference photo, lives nowhere in the accent ramp. */
const PALETTE: Record<string, [number, number, number]> = {
  u_ground: [0.018, 0.006, 0.006], // near-black maroon, the sky and far corners
  u_groundWarm: [0.1, 0.018, 0.014], // the warmer wash gathered low and toward the flare
  u_haze: [0.32, 0.05, 0.03], // the faint red blob adrift in the upper field
  u_crimson: [0.82, 0.08, 0.04], // each strand's colour furthest from the flare
  u_amber: [0.97, 0.44, 0.09], // each strand's mid-travel colour
  u_gold: [1.0, 0.78, 0.28], // each strand's colour just before it turns white
  u_core: [1.0, 0.97, 0.9], // the flare itself, and every strand's hot core
};

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_groundWarm;
uniform vec3 u_haze;
uniform vec3 u_crimson;
uniform vec3 u_amber;
uniform vec3 u_gold;
uniform vec3 u_core;

uniform vec4  u_readA;
uniform float u_guard;

const float VP_Y0 = 0.60; /* the flare's resting height, uv space */
const float VP_X0 = 0.42; /* the flare's resting x, P-space (right of centre) */
const int   N_STRANDS = 26;

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 flare leans toward the pointer, a few percent of the frame, the
     whole strand bundle dragged along with it since every strand
     converges on this same point */
  float pep = u_pointer.z;
  float VP_X = VP_X0 + (u_pointer.x - 0.5) * 0.5 * pep;
  float VP_Y = VP_Y0 + (u_pointer.y - 0.5) * 0.22 * pep;

  vec2 vpP = vec2(VP_X, VP_Y - 0.5);
  vec2 vpP0 = vec2(VP_X0, VP_Y0 - 0.5); /* the streak's own anchor — it holds
    its line regardless of where the flare itself drifts */

  /* the ground: near-black maroon, warming low in the frame and gathering
     into a soft bloom around the flare itself */
  float lowWarm = smoothstep(0.85, 0.0, uv.y);
  vec3 col = mix(u_ground, u_groundWarm, lowWarm * 0.7);
  float distVP = length(P - vpP);
  col = mix(col, u_groundWarm, exp(-distVP * distVP * 1.1) * 0.8);

  /* a faint red glow adrift in the upper-left field, well clear of the
     strands and the flare, matching the reference photo's own stray bloom */
  vec2 hazeP = vec2(-aspect * 0.22, 0.28);
  float hazeDist = length(P - hazeP);
  col += u_haze * exp(-hazeDist * hazeDist * 9.0) * 0.55;

  /* ---- the strand bundle: each one converges on the flare in its own
     time, all of them riding one shared sine bulge so the bundle reads as
     a single sweeping S rather than a straight fan ------------------- */
  float t = clamp(uv.y / VP_Y, 0.0, 1.0);
  float above = 1.0 - smoothstep(0.97, 1.06, uv.y / VP_Y);

  for (int i = 0; i < N_STRANDS; i++) {
    float fi = float(i);
    float h1 = vhash(vec2(fi, 3.0));
    float h2 = vhash(vec2(fi, 8.0));
    float h3 = vhash(vec2(fi, 14.0));
    float h4 = vhash(vec2(fi, 19.0));

    float startX = mix(-1.15, 1.15, (fi + h1) / float(N_STRANDS)) * (aspect * 0.5);
    float ease = pow(t, mix(1.3, 3.4, h2));
    float bulge = mix(-0.22, 0.34, h3) * sin(t * 3.14159265) * (1.0 - t * 0.25);

    float curveX = mix(startX, VP_X, ease) + bulge;

    float thickness = mix(0.0045, 0.011, h4) * mix(1.15, 0.7, t);
    float dx = P.x - curveX;
    float core = exp(-dx * dx / (thickness * thickness));
    float glow = exp(-dx * dx / (thickness * thickness * 9.0)) * 0.45;

    float travel = 0.65 + 0.35 * sin(t * 7.0 - u_time * (0.7 + h1 * 0.6) - fi * 2.3);
    float approach = mix(0.55, 1.9, smoothstep(0.55, 1.0, t));
    float strandMask = (core + glow) * above * travel * approach;

    vec3 strandColor = mix(u_crimson, u_amber, smoothstep(0.0, 0.55, t));
    strandColor = mix(strandColor, u_gold, smoothstep(0.55, 0.85, t));
    strandColor = mix(strandColor, u_core, smoothstep(0.86, 1.0, t) * 0.85);

    col += strandColor * strandMask;
    col += u_core * core * above * travel * smoothstep(0.75, 1.0, t) * 0.5;
  }

  /* the flare itself: a soft wide bloom, a tight hot core, and a thin
     anamorphic streak riding through it, the way a real lens flares */
  float flareGlow = exp(-distVP * distVP * 3.2);
  float flareCore = exp(-distVP * distVP * 60.0);
  col += u_amber * flareGlow * 0.5;
  col += u_core * flareCore * 1.1;

  float streak = exp(-pow((P.y - vpP0.y) * 26.0, 2.0)) * exp(-abs(P.x - vpP0.x) * 0.55);
  col += mix(u_gold, u_core, 0.6) * streak * 0.4;

  /* the touch: a soft ember bloom trailing the cursor itself, the same
     "your touch joined the light" move RoadField / RakeField make */
  vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  vec2 toPtr = P - ptr;
  float dPtr = dot(toPtr, toPtr);
  col += u_amber * pep * exp(-dPtr * 10.0) * 0.4;
  col += u_core * pep * exp(-dPtr * 46.0) * 0.35;

  /* corner vignette, darkest opposite the flare where a title usually sits */
  vec2 vc = uv - 0.5;
  float vig = length(vc * vec2(1.0, 1.05));
  col *= mix(1.0, 0.62, smoothstep(0.48, 1.15, vig));

  col = max(col, 0.0);

  /* ---- the reading guard (see TileField for the full rationale) ---- */
  vec2 rg = abs(uv - u_readA.xy) / max(u_readA.zw, vec2(0.02));
  float md = mix(max(rg.x, rg.y), length(rg), 0.4);
  float guardBand = 1.0 - smoothstep(0.72, 2.1, md);
  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 IgnisFieldProps {
  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 IgnisField({ className, guardSelector = null }: IgnisFieldProps) {
  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 TorrentField: once painted, the last
         frame stays on screen while the surface is parked off-view. onLost
         still drops back to the CSS floor. */
      onLost: () => canvas.removeAttribute("data-shader"),
      maxPixels: 1_700_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(_30%_24%_at_62%_40%,oklch(0.95_0.05_85)_0%,transparent_14%_),radial-gradient(_48%_40%_at_62%_40%,oklch(0.72_0.19_55)_0%,transparent_38%_),conic-gradient(_from_150deg_at_62%_40%,oklch(0.5_0.2_25)_0deg,oklch(0.68_0.2_45)_55deg,oklch(0.8_0.17_70)_105deg,transparent_150deg,transparent_205deg,oklch(0.46_0.19_20)_255deg,oklch(0.58_0.2_35)_300deg,transparent_340deg_),radial-gradient(_26%_22%_at_30%_22%,oklch(0.34_0.15_25_/_0.55)_0%,transparent_72%_),oklch(0.025_0.018_25)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-conic-gradient(_from_200deg_at_62%_40%,transparent_0deg_3deg,oklch(0.55_0.2_40_/_0.35)_3deg_4deg,transparent_4deg_9deg_)] after:[mix-blend-mode:screen] after:[-webkit-mask-image:radial-gradient(_70%_70%_at_62%_40%,transparent_0_8%,#000_30%,transparent_78%_)] after:[mask-image:radial-gradient(_70%_70%_at_62%_40%,transparent_0_8%,#000_30%,transparent_78%_)]" />
      <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