IrisBackgroundsSpectral Coil
Spectral Coil
A fan of raytraced blades strung along one line, closing to a sliver at each cool-to-warm tip and opening full width in a lit magenta belly between them.
Spectral Coil
Forty-six flat blades sit along a single line rather than around a circle, each one raytraced — one ray per pixel, nearest of forty-six plane hits — so the fan occludes itself the way a real deck of cards does. Each blade's tilt depends only on where it sits on that line: closed to an edge-on sliver at both tips, opening wider toward the middle until it sits almost face-on in a wide, lit belly. The colour rides the same line — cool blue at one tip, warm citrus at the other, a saturated magenta where the fan opens widest — over a soft pale-lavender ground of its own.
The cursor is a camera here, not a light: move it and the whole fan orbits — x swings it round, y tips it — while easing a little further open or shut at the same time, on top of a slow idle sway that keeps it from ever sitting perfectly still with nobody pointing at it. Move away and the grip eases back to that idle sway rather than snapping.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { CoilField } from "./CoilField";
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">
<CoilField />
</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 coil — a single strip of near-black-free colour, fanned out
* of fifty-two flat blades strung along one line rather than twisted as a
* continuous surface, so the fan reads as a stack of separate plates: an
* analytic ray/plane hit per blade (nearest of fifty-two), same technique as
* `ApertureField`'s ring, just run along a line instead of around a circle.
* Each blade's tilt is a function of where it sits on that line, closing
* toward the two ends and opening toward the middle — edge-on slivers of
* cool blue at one tip, warm citrus at the other, a wide lit magenta belly
* between them where the fan is most face-on to the camera. The gradient
* rides the strip's own length, not the screen, so it stays put as the
* piece turns.
*
* The line itself isn't straight the whole way: the cool tip curls into a
* short hook (a blade centre's own offset from the line, its angle sweeping
* open and its radius decaying, both driven by distance from that tip), so
* the fan reads as a coil caught mid-uncurl rather than a flat fanned-out
* deck. The curl fully resolves by about a third of the way down the strip
* — the rest is the plain straight line.
*
* The pointer is a real camera: it orbits the whole strip (yaw follows x,
* pitch follows y) and eases the fan open or shut a little further, on top
* of a slow idle sway that keeps it from ever sitting dead still with no
* cursor at all — same presence-eased grip `shader-surface` gives every
* field here.
*
* 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-coilfield__floor`
* underneath visible.
*
* Like `CascadeField` and `ShaftField`, the palette is a fixed set of five
* colours passed as uniforms rather than read from the site's dark ramp —
* this pale lavender ground and its blue/magenta/citrus strip are their own
* mood, not the portfolio's.
*
* Reading guard: when `guardSelector` resolves to an element, the field
* measures that block every frame and clamps its own luminance under a
* ceiling in that region (hue and saturation untouched). `null` (the
* default) turns the guard off — for decorative use where nothing sits on
* top of it.
*/
/* Palette, sRGB 0–1. Uniforms, not tokens — see the note above. */
const PALETTE: Record<string, [number, number, number]> = {
u_bgA: [0.62, 0.60, 0.8], // ground, lighter corner
u_bgB: [0.49, 0.47, 0.72], // ground, deeper corner
u_blue: [0.3, 0.42, 0.88], // one tip of the strip
u_violet: [0.52, 0.14, 0.72], // the belly, most face-on to camera
u_citrus: [0.8, 0.85, 0.45], // the other tip
};
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_bgA;
uniform vec3 u_bgB;
uniform vec3 u_blue;
uniform vec3 u_violet;
uniform vec3 u_citrus;
uniform vec4 u_readA;
uniform float u_guard;
#define NBLADES 52
mat2 r2(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }
vec3 rotX(vec3 p, float a) { p.yz = r2(a) * p.yz; return p; }
vec3 rotY(vec3 p, float a) { p.xz = r2(a) * p.xz; return p; }
vec3 rotZ(vec3 p, float a) { p.xy = r2(a) * p.xy; return p; }
/* strip geometry, in world units */
const float HALF_LEN = 3.6; /* half the strip's length, along local y */
const float RX = 1.02; /* a blade's long radius, face-on */
const float HALF_THICK = 0.078; /* a blade's half-thickness along the strip */
const float TWIST_MAX = 1.28; /* radians of tilt, centre to either tip */
/* the hook: a curl at the cool tip only. A blade's centre is nudged off the
straight line by an offset whose angle sweeps open fast and whose radius
decays fast, both driven off the same distance-from-the-tip — so the curl
plays out over roughly the first third of the strip and the rest sits on
the plain straight line, undisturbed. */
const float HOOK_TURNS = 0.62;
const float HOOK_RADIUS = 0.62;
const float HOOK_SPAN = 0.85; /* how much of the -1..1 range the curl eats */
vec3 hookOffset(float s) {
float k = clamp((s + 1.0) / HOOK_SPAN, 0.0, 1.0);
float ang = HOOK_TURNS * 6.28318530718 * smoothstep(0.0, 0.55, k);
float rad = HOOK_RADIUS * (1.0 - smoothstep(0.0, 1.0, k));
return rad * vec3(cos(ang), 0.0, sin(ang));
}
vec3 gradient(float t) {
vec3 c = mix(u_blue, u_violet, smoothstep(0.0, 0.52, t));
c = mix(c, u_citrus, smoothstep(0.48, 1.0, t));
return c;
}
/* One flat elliptical blade per iteration, nearest of NBLADES ray/plane
hits. Each blade's tilt is a function of its position on the line rather
than a constant rate of twist — that is what lets it close toward a
sliver at both ends and open to full width once in the middle, instead of
repeating the same open/close cycle down its length. Returns -1 in .x
when nothing was hit, so the caller can fall back to the ground without
a second branch mirroring this one. */
vec3 renderCoil(vec3 ro, vec3 rd, float phase, float spread, vec3 lightDir, float glow) {
float bestT = 1e9;
float bU = 0.0, bV = 0.0, bY = 0.0;
vec3 bNrm = vec3(0.0);
bool hit = false;
for (int i = 0; i < NBLADES; i++) {
float s = (float(i) / float(NBLADES - 1) - 0.5) * 2.0; /* -1..1 */
float y = s * HALF_LEN;
float tw = TWIST_MAX * spread * s + phase;
float ct = cos(tw), st = sin(tw);
vec3 w = vec3(ct, 0.0, st);
vec3 nrm = vec3(-st, 0.0, ct);
vec3 ctr = vec3(0.0, y, 0.0) + hookOffset(-s);
float denom = dot(rd, nrm);
if (abs(denom) < 1e-4) continue;
float t = dot(ctr - ro, nrm) / denom;
if (t <= 0.05 || t >= bestT) continue;
vec3 P = ro + rd * t;
float u = dot(P - ctr, vec3(0.0, 1.0, 0.0)) / HALF_THICK;
float v = dot(P - ctr, w) / RX;
if (u * u + v * v < 1.0) {
bestT = t; bU = u; bV = v; bY = y; bNrm = nrm; hit = true;
}
}
if (!hit) return vec3(-1.0);
/* the gradient rides the strip's own length — flipped so the cool end
lands where the base camera tilt puts it top-left, citrus bottom-right */
float tCol = 1.0 - clamp((bY + HALF_LEN) / (2.0 * HALF_LEN), 0.0, 1.0);
vec3 albedo = gradient(tCol);
float diff = max(dot(bNrm, lightDir), 0.0);
vec3 h = normalize(lightDir - rd);
float spec = pow(max(dot(bNrm, h), 0.0), 46.0);
float fres = pow(1.0 - max(dot(bNrm, -rd), 0.0), 2.4);
/* a soft rim at each blade's own edge, so neighbours read as separate
plates even where they overlap in the fan's open middle */
float edge = smoothstep(0.74, 0.999, bU * bU + bV * bV);
vec3 col = albedo * (0.5 + 0.55 * diff);
col += vec3(1.0) * spec * (0.55 + glow * 0.4);
col += mix(albedo, vec3(1.0), 0.5) * fres * (0.30 + glow * 0.25);
col += mix(albedo, vec3(1.0), 0.65) * edge * 0.22;
col *= mix(1.0, 0.55, smoothstep(8.5, 13.0, bestT)); /* the far tip recedes */
return max(col, 0.0);
}
void main() {
vec2 res = u_res / u_scale;
vec2 fc = gl_FragCoord.xy / u_scale;
vec2 uv = fc / res; /* 0..1, y up */
vec2 p = (fc - 0.5 * res) / res.y;
float pres = u_pointer.z;
vec2 ptr = (u_pointer.xy - 0.5) * 2.0; /* -1..1 */
/* the fixed diagonal the piece sits at, plus a slow idle sway so it is
never dead still with no cursor around, plus the pointer's own orbit */
float idleRoll = sin(u_time * 0.05) * 0.05;
float idlePitch = cos(u_time * 0.035) * 0.03;
float roll = 1.18 + idleRoll * (1.0 - pres) + ptr.x * 0.28 * pres;
float pitch = 0.22 + idlePitch * (1.0 - pres) - ptr.y * 0.22 * pres;
float yaw = ptr.x * 0.14 * pres;
/* the cursor also nudges where the fan's phase sits and how far it opens
— a second, cheaper kind of response than moving the camera alone */
float phase = ptr.x * 0.55 * pres;
float spread = 1.0 + 0.16 * sin(u_time * 0.05) * (1.0 - pres) + ptr.y * 0.22 * pres;
vec3 ro = vec3(0.0, 0.0, 6.9);
vec3 rd = normalize(vec3(p, -1.6));
ro = rotZ(ro, roll); rd = rotZ(rd, roll);
ro = rotX(ro, pitch); rd = rotX(rd, pitch);
ro = rotY(ro, yaw); rd = rotY(rd, yaw);
/* the ground: a soft lavender, brighter toward the upper-right and lifted
a little more where the strip itself sits */
vec3 bg = mix(u_bgB, u_bgA, clamp(uv.x * 0.6 + uv.y * 0.55, 0.0, 1.0));
bg += (u_bgA - u_bgB) * 0.18 * (1.0 - length(p) * 0.5);
vec3 lightDir = normalize(vec3(-0.35, 0.55, 0.75));
float glow = 0.15 + 0.85 * pres;
vec3 hitCol = renderCoil(ro, rd, phase, spread, lightDir, glow);
vec3 col = hitCol.x < 0.0 ? bg : hitCol;
/* ---- the reading guard (see TileField for the full rationale) ---- */
vec2 rd2 = abs(uv - u_readA.xy) / max(u_readA.zw, vec2(0.02));
float m = mix(max(rd2.x, rd2.y), length(rd2), 0.4);
float band = 1.0 - smoothstep(0.72, 2.1, m);
col = mix(col, holdUnder(col, 0.09), band * u_guard);
col = max(col, 0.0);
col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.2 / 255.0);
gl_FragColor = vec4(col, 1.0);
}
`;
export interface CoilFieldProps {
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 CoilField({ className, guardSelector = null }: CoilFieldProps) {
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 SilkField / CascadeField: once painted,
the last frame stays on screen while the surface is parked
off-view. onLost drops back to the CSS floor. */
onLost: () => canvas.removeAttribute("data-shader"),
maxPixels: 1_800_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%_100%_at_82%_14%,oklch(0.85_0.045_295)_0%,transparent_62%_),oklch(0.71_0.065_292)] before:content-[''] before:absolute before:[inset:50%_auto_auto_50%] before:[width:155%] before:[height:15%] before:[transform:translate(-50%,-50%)_rotate(-23deg)] before:[background:repeating-linear-gradient(_90deg,oklch(1_0_0_/_0.28)_0%_2%,transparent_2%_7%_),linear-gradient(_90deg,oklch(0.58_0.18_258)_0%,oklch(0.46_0.24_322)_46%,oklch(0.46_0.24_322)_56%,oklch(0.85_0.16_112)_100%_)] before:[filter:blur(1px)] before:[-webkit-mask-image:linear-gradient(_90deg,transparent_0%,#000_14%,#000_86%,transparent_100%_)] before:[mask-image:linear-gradient(_90deg,transparent_0%,#000_14%,#000_86%,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>
);
}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