Skip to content

IrisBackgroundsEmber Drift

Ember Drift

A diagonal spray of glowing sparks streaming up and out of frame, bending toward a passing hand.

trail

Ember Drift

Motion-blurred sparks stream up and to the right over a warm near-black ground, each with a hot near-white core. The ones nearest the viewer read as big, soft, out-of-focus bokeh; the far ones as fine fast threads. Sparks are born dense and bright at the lower-left source and cool to dim umber as they climb out of frame.

The pointer disturbs the shower: sparks near the cursor bend toward it, their flight quickens, the nearest cores flare, and a soft warm bloom follows the cursor.

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

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">
      <EmberField />
    </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 flying embers — a diagonal spray of glowing sparks
 * streaming up and to the right over a warm near-black ground. Each spark is a
 * motion-blurred streak with a hot near-white core; the ones nearest the
 * viewer read as big, soft, out-of-focus bokeh, the far ones as fine fast
 * threads. Sparks are born dense and bright at the lower-left source and cool
 * to dim umber as they climb out of frame — the look of a spark shower off a
 * grinding wheel or a struck fire, caught in a slow shutter.
 *
 * The pointer disturbs the shower: sparks near the cursor bend toward it,
 * their flight quickens, the nearest cores flare, and a soft warm bloom
 * follows the cursor — as if a hand were passing through the stream.
 *
 * One of the reusable background fields (`TileField`, `SilkField`,
 * `AuroraVeil`, `SpineField`, `SlabField`, `RakeField`, `FilamentField`). 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-emberfield__floor` underneath visible (a still
 * composed frame in the same palette, never a blank box).
 *
 * Like `FilamentField` / `RakeField` (and unlike `SilkField`), the palette is
 * a fixed warm set passed as uniforms rather than read from the token ramp —
 * the near-white spark core 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_ground: [0.018, 0.01, 0.006], // the warm near-black behind everything
  u_ember: [0.28, 0.095, 0.026], // spent sparks, and the ambient warm floor
  u_amber: [0.98, 0.47, 0.12], // the body of a live spark and the pointer bloom
  u_flare: [1.0, 0.85, 0.6], // the near-white core riding the hottest sparks
};

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_ember;
uniform vec3 u_amber;
uniform vec3 u_flare;

/* 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 int LAYERS = 5;

/* One drifting set of sparks travelling along +x of the passed-in frame.
   Returns the soft motion-blurred body; writes the hot core into 'core'.
   sharpK: 0 = near, big and soft; 1 = far, fine and crisp. */
float sparks(vec2 p, float seed, float laneFreq, float partFreq,
             float speed, float sharpK, float t, out float core) {
  float lane   = p.y * laneFreq + seed * 17.0;
  float laneId = floor(lane);
  float ly     = fract(lane) - 0.5;

  /* a slow warp bows the whole lane so the stream is never ruler-straight */
  ly += fbm(vec2(p.x * 0.45 + seed * 3.0, laneId * 1.9)) * 0.42;

  /* particles spaced along the lane, marching with time */
  float flow = p.x * partFreq - t * speed;
  float pid  = floor(flow);
  float fx   = fract(flow) - 0.5;

  float h  = dotHash(vec2(pid, laneId + seed * 5.0));
  float on = step(0.56, h);                       /* gaps: not every cell is lit */
  ly += (dotHash(vec2(pid * 1.7, laneId)) - 0.5) * 0.7 / laneFreq;

  /* elongated blob — a long tail trailing behind (-x), a tight leading edge */
  float back  = exp(-fx * fx * mix(9.0, 4.0, sharpK));
  float front = exp(-fx * fx * mix(60.0, 26.0, sharpK));
  float along = fx < 0.0 ? back : front;
  float body  = along * exp(-ly * ly * mix(70.0, 520.0, sharpK)) * on;

  /* each spark twinkles over its flight */
  float tw = 0.55 + 0.45 * sin(t * (2.0 + h * 5.0) + h * 40.0);
  body *= tw;

  core = exp(-fx * fx * mix(26.0, 150.0, sharpK))
       * exp(-ly * ly * mix(320.0, 2200.0, sharpK)) * on * (0.55 + 0.6 * tw);
  return body;
}

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);

  vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  vec2 toP = P - ptr;
  float pull = u_pointer.z * exp(-dot(toP, toP) * 24.0);

  /* the cursor bends the nearby sparks toward it and quickens their flight */
  vec2  Pw = P - toP * pull * 0.28;
  float localSpeed = 1.0 + pull * 2.4;

  /* flow axis: up and to the right, with a slow global sway */
  float baseAng = 0.34 + sin(u_time * 0.05) * 0.03;
  vec2  origin  = vec2(-aspect * 0.55, -0.62);           /* the spark source, off lower-left */

  /* sparks brighten at the source and cool as they climb out of frame; the
     shower is a swath, densest at the source corner and thinning outward */
  float alongAxis = dot(P - origin, vec2(cos(baseAng), sin(baseAng)));
  float cool   = smoothstep(2.6, -0.2, alongAxis);       /* 1 near source .. 0 far */
  float corner = smoothstep(3.0, 0.2, length(P - origin));
  float spray  = (0.22 + 0.78 * cool) * mix(0.45, 1.0, corner);

  vec3 col = mix(u_ground, u_ember, 0.10 + 0.5 * cool);
  float hot = 0.0;

  for (int i = 0; i < LAYERS; i++) {
    float fi = float(i);
    float k  = fi / float(LAYERS - 1);                   /* 0 near .. 1 far */
    float seed = fi * 2.399;

    float ang = baseAng + (dotHash(vec2(seed, 3.0)) - 0.5) * 0.42;
    float ca = cos(ang), sa = sin(ang);
    vec2 rp = mat2(ca, sa, -sa, ca) * (Pw - origin);
    rp.y += fbm(vec2(rp.x * 0.3 + seed, seed * 2.0)) * mix(0.5, 0.14, k);

    float laneFreq = mix(3.6, 21.0, k);
    float partFreq = mix(1.2, 4.6, k);
    float speed    = mix(0.85, 3.2, k) * (0.7 + 0.6 * dotHash(vec2(seed, 9.0))) * localSpeed;
    float sharpK   = mix(0.05, 1.0, k);

    float core;
    float s = sparks(rp, seed, laneFreq, partFreq, speed, sharpK, u_time, core);

    float w = mix(1.35, 0.7, k) * spray;

    vec3 tint = mix(u_ember, u_amber, smoothstep(0.0, 0.5, s));
    tint = mix(tint, u_amber, cool * 0.4);
    col += tint * s * w * 1.9;
    col += u_amber * s * s * w * 0.8;                    /* bloom off the body */
    hot += core * w * mix(1.2, 0.75, k);
  }

  col += u_flare * hot * (0.8 + 0.5 * cool);

  /* the touch: a warm radial bloom, and the nearest cores flare harder */
  col += u_amber * pull * 0.55;
  col += u_flare * pull * hot * 0.8;

  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 md = mix(max(rd.x, rd.y), length(rd), 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 EmberFieldProps {
  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 EmberField({ className, guardSelector = null }: EmberFieldProps) {
  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 / FilamentField: 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(_120%_110%_at_10%_98%,oklch(0.64_0.19_52)_0%,transparent_46%_),radial-gradient(_90%_78%_at_34%_82%,oklch(0.5_0.15_44)_0%,transparent_52%_),radial-gradient(_120%_120%_at_92%_4%,oklch(0.05_0.02_40)_0%,transparent_56%_),oklch(0.045_0.016_40)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-linear-gradient(28deg,transparent_0_6px,oklch(0.83_0.17_58_/_0.5)_6px_7px),repeating-linear-gradient(24deg,transparent_0_11px,oklch(0.7_0.15_48_/_0.3)_11px_13px)] after:[mask-image:linear-gradient(38deg,#000_0_28%,oklch(0_0_0_/_0.3)_62%,transparent_88%)] after:[-webkit-mask-image:linear-gradient(38deg,#000_0_28%,oklch(0_0_0_/_0.3)_62%,transparent_88%)]" />
      <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