IrisBackgroundsEmber Drift
Ember Drift
A diagonal spray of glowing sparks streaming up and out of frame, bending toward a passing hand.
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.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
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>
);
}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
Reads and answers every request himself