Skip to content

IrisBackgroundsSkein

Skein

Seven nested light-blue arcs of particle dust sweeping up from the left edge and back down past the right, over a navy ground that lifts to a soft glow at the crest — a direct build of the reference photo.

arc

Skein

Seven arcs, nested loosely rather than concentric, sweep up from below the left edge, crest left-of-centre, and run back down toward the right — each one drawn not as a stroke but as a stream of particle dust: dense and near-solid along its own centreline, thinning into scattered specks the further a point drifts from it. The innermost arcs read as a tight, bright cable; the outermost fray wider and speckle more, the way the reference photo's own outer trails loosen. A soft blue glow sits over the crest, cooling into a darker navy toward the corners.

The dust never sits still: each arc's particle field slides slowly along its own length, alternating direction arc to arc, so the bundle reads as light actually travelling rather than a printed texture. The pointer takes hold two ways — near the crest, the whole bundle leans toward it, the way pulling one end of a loose cable drags the rest; everywhere else, a soft spotlight rides the cursor directly, brightening whichever arcs pass under it.

  • cool
  • pointer-driven
  • particle
  • motion-trail
Family
arc
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

example.tsx
import { SkeinField } from "./SkeinField";

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">
      <SkeinField />
    </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 built from one reference photo: a bundle of seven
 * nested light-blue arcs, sweeping up from below the left edge, cresting
 * left-of-centre, and running back down toward the right — each arc drawn
 * not as a solid stroke but as a stream of particle dust, dense and bright
 * along its own centreline and thinning into scattered specks the further
 * a point sits from it. Inner arcs read as a tighter, brighter cable;
 * outer ones spread wider and speckle more, the way the reference photo's
 * outermost trails fray. A soft blue glow sits over the crest, cooling to
 * a darker navy toward the corners.
 *
 * The dust is never still: each arc's particle field slides slowly along
 * its own length, alternating direction arc to arc, so the bundle reads as
 * light actually travelling rather than a printed texture.
 *
 * The pointer does two things: near the crest, the whole bundle leans
 * toward it, the way pulling on one end of a loose cable bundle drags the
 * rest; and everywhere else, a soft spotlight of light rides the cursor
 * directly, brightening whatever arcs pass under it. Move away and both
 * ease back to rest.
 *
 * One of the reusable background fields (`NovaField`, `CoronaField`, …).
 * 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-skeinfield__floor`
 * underneath visible — a still frame in the same palette, never a blank
 * box.
 *
 * Like `NovaField`, the palette is a fixed set passed as uniforms rather
 * than read from the accent ramp — the reference photo's saturated blue
 * lives nowhere in the site's ramp, so the CSS floor carries the colour
 * rather than a 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). `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_deep: [0.035, 0.075, 0.16], // darkest navy, the corners
  u_navy: [0.1, 0.19, 0.36], // mid ground behind the bundle
  u_glow: [0.42, 0.6, 0.82], // soft ambient wash over the crest
  u_particle: [0.62, 0.78, 0.95], // the dust each arc is made of
  u_core: [0.93, 0.97, 1.0], // the hot white centreline
};

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_deep;
uniform vec3 u_navy;
uniform vec3 u_glow;
uniform vec3 u_particle;
uniform vec3 u_core;

uniform vec4  u_readA;
uniform float u_guard;

const int STRANDS = 7;

/* One arc's particle-dust plus hot centreline, worked in the arc's own
   polar space: theta runs its length, radial distance from the arc's own
   circle is the perpendicular offset a dust speck can drift from it. */
float strandGlow(vec2 p, vec2 center, float radius, float widthAcross,
                  float density, float speed, float seed, out float core) {
  vec2 rel = p - center;
  float r = length(rel);
  float theta = atan(rel.x, rel.y);           /* 0 = straight up from centre */
  float edgeFade = 1.0 - smoothstep(0.95, 1.55, abs(theta));

  float dRadial = r - radius;
  core = exp(-dRadial * dRadial / (2.0 * widthAcross * widthAcross * 0.05));

  float along = theta * radius - u_time * speed;
  float across = dRadial;

  float pitchAlong = 0.048;
  float pitchAcross = widthAcross * 0.55;
  vec2 cellId = floor(vec2(along / pitchAlong, across / pitchAcross));
  float h0 = vhash(cellId + seed);

  float acrossCenter = (cellId.y + 0.5) * pitchAcross;
  float densFall = exp(-acrossCenter * acrossCenter / (2.0 * widthAcross * widthAcross));

  float dust = 0.0;
  if (h0 < density * densFall) {
    vec2 jitter = vec2(vhash(cellId + vec2(1.7, 3.1) + seed),
                        vhash(cellId + vec2(9.2, 4.6) + seed)) - 0.5;
    vec2 particle = (cellId + 0.5 + jitter * 0.9) * vec2(pitchAlong, pitchAcross);
    vec2 d = vec2(along, across) - particle;
    float sizeR = mix(0.16, 0.62, vhash(cellId + vec2(5.5, 2.2) + seed)) * pitchAcross;
    dust = exp(-dot(d, d) / (sizeR * sizeR * 0.5));
  }

  return (core * 0.9 + dust) * edgeFade;
}

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 bundle leans toward the pointer when it's near the crest, the way
     pulling one end of a loose cable drags the rest of it */
  vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
  vec2 apex = vec2(-0.12, 0.14);
  vec2 toApex = ptr - apex;
  float grip = u_pointer.z * exp(-dot(toApex, toApex) * 1.4);
  vec2 bundleShift = toApex * grip * 0.3;

  vec2 baseCenter = vec2(-0.12, -1.85) + bundleShift;

  vec3 col = mix(u_navy, u_deep, smoothstep(0.15, 1.3, length(p - vec2(0.0, -0.15))));

  vec2 glowAt = apex + bundleShift * 0.6;
  float dGlow = length((p - glowAt) * vec2(1.0, 1.35));
  col += u_glow * exp(-dGlow * dGlow * 1.1) * 0.85;

  float hotSum = 0.0;
  for (int i = 0; i < STRANDS; i++) {
    float fi = float(i);
    float t = fi / float(STRANDS - 1);                 /* 0 inner .. 1 outer */
    float radius = mix(1.72, 2.28, t);
    vec2 center = baseCenter + vec2(mix(0.0, -0.05, t), 0.0);
    float width = mix(0.018, 0.05, t);
    float density = mix(0.85, 0.34, t);
    float speed = mix(0.05, 0.14, t) * (mod(fi, 2.0) < 0.5 ? 1.0 : -1.0);
    float seed = fi * 11.7;

    float core;
    float g = strandGlow(p, center, radius, width, density, speed, seed, core);

    vec3 tint = mix(u_particle, u_core, core);
    col += tint * g * mix(1.5, 0.85, t);
    hotSum += core * mix(0.5, 0.15, t);
  }

  /* the pointer's own spotlight, everywhere on the field, not only near
     the crest — a soft lift that rides the cursor directly */
  float distP = dot(p - ptr, p - ptr);
  float spot = exp(-distP * 4.5) * u_pointer.z;
  col += u_core * spot * 0.22;
  col += u_glow * hotSum * 0.3;

  vec2 corner = uv - 0.5;
  float cvig = length(corner * vec2(1.05, 1.2));
  col *= mix(1.0, 0.62, smoothstep(0.45, 1.0, cvig));

  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.1), guardBand * u_guard);

  col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.0 / 255.0);

  gl_FragColor = vec4(col, 1.0);
}
`;

export interface SkeinFieldProps {
  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 SkeinField({ className, guardSelector = null }: SkeinFieldProps) {
  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 NovaField / CoronaField: 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: 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%_130%_at_44%_168%,transparent_46%,oklch(0.82_0.07_235_/_0.5)_52%,oklch(0.68_0.13_235_/_0.38)_58%,transparent_64%_),radial-gradient(_45%_55%_at_40%_8%,oklch(0.62_0.09_235_/_0.55)_0%,transparent_68%_),radial-gradient(_60%_70%_at_45%_60%,oklch(0.28_0.08_250)_0%,oklch(0.14_0.05_255)_70%,oklch(0.06_0.03_255)_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