Skip to content

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.

coil

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.

  • warm-cool
  • pointer-driven
  • raytraced
Family
coil
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

example.tsx
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>
  );
}

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