IrisBackgroundsSpectral Linen
Spectral Linen
A fine woven linen grain washed in one slow radial spectrum — saturated blue at the centre, sweeping the long way round through green, gold and rust to magenta and violet at the far corners.
Spectral Linen
No painted gradient sits under this one — the colour is a single hue sweep read off distance from a centre point: blue at the core, turning the long way round through green, gold and rust as it moves out, and wrapping into magenta and violet only at the very edges, the way shot fabric shifts colour as it turns away from the light. Over that sits a fine woven grain built from two crossed noise fields, one stretched tall and one stretched wide, so the crossing reads as cloth rather than a printed scanline, with a third, much finer layer standing in for the fibre's own texture.
The colour never moves for the pointer — only the cloth does: the weave bends outward from the cursor, threads visibly displaced the way real linen gives under a fingertip, with the spectrum underneath simply showing through whatever shape the fabric holds at that moment. Left alone, the centre still drifts on its own, slowly, so it never sits as a static swatch.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { SpectralLinenField } from "./SpectralLinenField";
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">
<SpectralLinenField />
</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 fine woven linen, lit by one slow radial spectrum
* rather than any painted colour: a saturated blue holds the centre, and
* hue sweeps the long way round — through green, gold and rust — out to
* magenta and violet at the far corners, the way a length of shot fabric
* shifts colour as it turns away from the light.
*
* The weave itself is two anisotropic noise fields rather than a ruled
* grid — one stretched tall (fine horizontal striation, slow drift across
* the width) and one stretched wide (the same, turned ninety degrees) —
* summed so neither axis dominates and the crossing reads as cloth rather
* than a scanline pattern. A third, isotropic layer at much higher
* frequency stands in for the fibre's own grain. None of the three are
* animated by resampling — a real weave doesn't scroll — so the only
* motion is a slow overall brightness breathe and the hue centre's own
* idle drift.
*
* The colour is a fixed layer — the spectrum's centre only ever drifts on
* its own, never toward the cursor. What the pointer touches is the cloth
* itself: the weave bends outward from it, threads visibly displaced the
* way real linen gives under a fingertip, and the colour underneath just
* shows through whatever shape the fabric is in at that moment rather than
* moving to meet the touch.
*
* One of the reusable background fields (`GauzeField`, `SpectrumGlassField`,
* …). 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-spectrallinenfield__floor`
* underneath visible — a still frame in the same spectrum, never a blank box.
*
* The hue sweep is procedural (HSV), not the accent ramp — a full spectrum
* lives nowhere in the site's own single-hue amber, same reasoning as
* `SpectrumGlassField`.
*
* 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.
*/
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 vec4 u_readA;
uniform float u_guard;
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
void main() {
vec2 res = u_res / u_scale;
vec2 uv = gl_FragCoord.xy / u_scale / res; /* 0..1, y up */
float aspect = res.x / max(res.y, 1.0);
vec2 P = (uv - 0.5) * vec2(aspect, 1.0);
/* ---- the spectrum's centre: idle drift only, never the pointer ---- */
vec2 center = vec2(sin(u_time * 0.045) * 0.035, cos(u_time * 0.037) * 0.03);
vec2 Q = P - center;
float maxRadius = length(vec2(aspect, 1.0)) * 0.5;
float t = clamp(length(Q) / maxRadius, 0.0, 1.3);
/* eased so blue holds a broad centre and the sweep does most of its work
in the outer half, rather than burning through green by mid-frame */
float tt = pow(t, 1.7);
/* blue at the core, swept the long way round through green, gold and
rust, wrapping into magenta and violet at the far corners */
float hueBase = 0.667 + sin(u_time * 0.03) * 0.012;
float hue = fract(hueBase - tt * 0.87);
float sat = mix(0.42, 0.66, smoothstep(0.0, 0.75, tt));
float val = mix(0.64, 0.40, smoothstep(0.3, 1.1, tt));
/* ---- the weave: two anisotropic noise fields, crossed, bent by touch ----
the pointer pushes the sampling coordinate itself outward from the
cursor, so the threads visibly give under it — the colour above never
moves, only the cloth's own shape does. */
vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
vec2 dPtr = P - ptr;
float distPtr = length(dPtr);
vec2 pushDir = dPtr / max(distPtr, 1e-4);
float push = u_pointer.z * exp(-distPtr * distPtr * 5.0) * 0.09;
vec2 Pw = P + pushDir * push;
float threadsH = fbm(vec2(Pw.x * 3.0, Pw.y * 220.0));
float threadsV = fbm(vec2(Pw.x * 220.0, Pw.y * 3.0));
float weave = (threadsH * 0.55 + threadsV * 0.55) * (1.0 + 0.05 * sin(u_time * 0.15));
float fibre = vnoise(Pw * 620.0) * 0.05;
val += weave * 0.15 + fibre;
val = clamp(val, 0.04, 0.94);
vec3 col = hsv2rgb(vec3(hue, sat, val));
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 SpectralLinenFieldProps {
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 SpectralLinenField({ className, guardSelector = null }: SpectralLinenFieldProps) {
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: ["u_readA", "u_guard"],
onInit: (gl, u) => {
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 GauzeField / SpectrumGlassField: 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_000_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(_130%_130%_at_48%_52%,oklch(0.62_0.15_240)_0%,oklch(0.66_0.15_190)_22%,oklch(0.68_0.16_140)_38%,oklch(0.78_0.17_95)_52%,oklch(0.62_0.18_45)_66%,oklch(0.46_0.16_350)_82%,oklch(0.34_0.13_300)_100%_)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-linear-gradient(_0deg,oklch(0.05_0.01_264_/_0.08)_0_1px,transparent_1px_4px,oklch(1_0_0_/_0.05)_4px_5px,transparent_5px_8px_),repeating-linear-gradient(_90deg,oklch(0.05_0.01_264_/_0.08)_0_1px,transparent_1px_4px,oklch(1_0_0_/_0.05)_4px_5px,transparent_5px_8px_)] after:[mix-blend-mode:overlay]" />
<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