IrisBackgroundsKnot
Knot
The catalogue's earlier take on the same idea: exactly four ribbons — a violet pair and a cyan pair — running long and near-straight before hooking sharply into one vanishing point, kept here for comparison against `Nova`'s perspective rebuild.
Knot
A violet pair and, past a visible dark gap, a cyan pair spread near the top of the frame and mirror near the bottom. Each pair runs long and almost straight from the left edge, the bend held off until the last moment, then hooks sharply in to one bright vanishing point left-of-centre — an eased line from a left-edge height to a right-edge height with a late, sharp turn near the middle, rather than a curve read off a shared elliptical coordinate system. There it flares into a tight white core with a lens-flare star, before the same eight ribbons peel back out and run nearly straight the rest of the way to the right edge.
Each ribbon carries one bright comet, born at the left edge, that sweeps to the right edge and loops on its own phase. The cursor takes hold of the vanishing point itself, cinching it toward the cursor's y and brightening the burst.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { KnotField } from "./KnotField";
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">
<KnotField />
</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: exactly four ribbons —
* a violet pair and, past a visible dark gap, a cyan pair — spread near the
* top of the frame and mirrored near the bottom. Each pair runs long and
* almost straight from the left edge, the bend held off until the last
* moment, then hooks sharply in to one bright vanishing point left-of-
* centre. There it flares into a tight white core with a lens-flare star —
* one long horizontal spike, a shorter vertical one, two thin diagonals —
* before the same eight ribbons peel back out, quickly at first and then
* levelling off, and run nearly straight the rest of the way to the right
* edge, closer to the centre line than they started. Near-black navy
* ground, corners held a little darker than the middle.
*
* This is the catalogue's earlier take on the same idea `NovaField` now
* covers — each ribbon is still its own eased curve from a left-edge y to
* a right-edge y with a late, sharp bend near the centre, rather than a
* curve read off a shared perspective/elliptical coordinate system. Kept
* here, registered under its own slug, so the two approaches sit side by
* side in the catalogue instead of one overwriting the other.
*
* The travel is a pulse per ribbon: each carries one bright comet, born at
* the left edge, that sweeps to the right edge and loops on its own phase
* — the light is actually going somewhere, not a printed streak. The lines
* themselves are held clean and steady, no organic wander, closer to the
* reference's crisp vector look than the softer photographic drift the
* rest of this catalogue favours.
*
* The pointer takes hold of the vanishing point itself: it cinches toward
* the cursor's y and the burst brightens, the way leaning in on the lens
* would pull the convergence taut. Move away and it eases back.
*
* One of the reusable background fields (`SlipstreamField`, `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-knotfield__floor`
* underneath visible — a still frame in the same palette, never a blank
* box.
*
* Like `SlipstreamField` / `CoronaField`, the palette is a fixed
* violet/cyan set passed as uniforms rather than read from the accent
* ramp — the reference photo's saturated magenta and cyan live 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_ground: [0.006, 0.009, 0.024], // near-black navy behind everything
u_deep: [0.05, 0.03, 0.16], // the narrow, soft violet bleed right at the burst
u_violetOuter: [0.58, 0.16, 0.86], // the outermost ribbon of each pair, magenta-violet
u_violetInner: [0.45, 0.14, 0.93], // just inside it, a touch more blue
u_cyanOuter: [0.16, 0.48, 0.97], // across the gap, the outer ribbon of the cyan pair
u_cyanInner: [0.36, 0.82, 1.0], // the innermost ribbon, closest to the burst
u_flare: [0.94, 0.97, 1.0], // the near-white core and its star
};
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_deep;
uniform vec3 u_violetOuter;
uniform vec3 u_violetInner;
uniform vec3 u_cyanOuter;
uniform vec3 u_cyanInner;
uniform vec3 u_flare;
uniform vec4 u_readA;
uniform float u_guard;
/* One ribbon: yStart holds almost the whole way from the left edge, the
bend arriving late and sharp on the approach to knotX (the reference's
long straight shaft, not an early curve); yCore is where it tightens
right at the vanishing point; yEnd is where it settles after peeling
back out, quick at first, then flat the rest of the way to the right
edge — always closer to centre than yStart, never as wide as the left
mouth. All three are absolute uv y (0 bottom..1 top). */
void strand(
inout vec3 col,
inout float hot,
vec2 uv,
float time,
float yStart,
float yCore,
float yEnd,
float thickness,
vec3 tint,
float phase,
float knotX,
float knotShift
) {
float x = uv.x;
float tPre = clamp(x / knotX, 0.0, 1.0);
float easedPre = smoothstep(0.4, 1.0, tPre);
float yPre = mix(yStart, yCore + knotShift, easedPre);
float tPost = clamp((x - knotX) / (1.0 - knotX), 0.0, 1.0);
float invPost = 1.0 - tPost;
float easedPost = 1.0 - invPost * invPost * invPost;
float yPost = mix(yCore + knotShift, yEnd, easedPost);
/* both branches equal yCore + knotShift exactly at knotX, so this seam
is exact */
float y = mix(yPre, yPost, smoothstep(knotX - 0.003, knotX + 0.003, x));
float dy = uv.y - y;
float body = exp(-dy * dy * thickness);
float core = exp(-dy * dy * thickness * 10.0);
float prox = 1.0 - smoothstep(0.0, 0.5, abs(x - knotX));
float hotProx = smoothstep(0.18, 0.92, prox);
/* the travelling comet: one bright pulse, born at the left edge,
sweeping to the right edge and looping, its own phase per ribbon */
float cometPhase = fract(time * 0.11 + phase);
float dComet = x - cometPhase;
float comet = exp(-dComet * dComet * 140.0);
comet *= smoothstep(0.0, 0.05, cometPhase) * (1.0 - smoothstep(0.93, 1.0, cometPhase));
float w = mix(0.65, 1.2, prox);
col += tint * body * w;
col += mix(tint, vec3(1.0), 0.35) * comet * body * 1.6;
hot += core * hotProx * 0.55 + core * comet * hotProx * 0.8;
}
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;
const float KNOT_X = 0.335;
const float KNOT_Y = 0.5;
/* the vanishing point: a fixed home, cinched toward the pointer's y —
the burst itself stays put */
vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
vec2 knotHome = (vec2(KNOT_X, KNOT_Y) - 0.5) * vec2(aspect, 1.0);
vec2 toP = knotHome - ptr;
float dP = length(toP);
float grip = u_pointer.z * exp(-dP * dP * 3.0);
vec2 knot = knotHome - toP * grip * 0.4;
float knotShift = knot.y - knotHome.y;
vec3 col = u_ground;
vec2 Pk = (uv - vec2(KNOT_X, KNOT_Y)) * vec2(aspect, 1.0);
float dKnot = length(Pk);
col += u_deep * exp(-dKnot * dKnot * 5.0) * 0.35;
float hot = 0.0;
/* top pair — violet outer/inner, then across the gap, cyan outer/inner */
strand(col, hot, uv, u_time, 0.92, 0.55, 0.72, 5200.0, u_violetOuter, 0.05, KNOT_X, knotShift);
strand(col, hot, uv, u_time, 0.86, 0.535, 0.67, 6400.0, u_violetInner, 0.42, KNOT_X, knotShift);
strand(col, hot, uv, u_time, 0.70, 0.52, 0.59, 7200.0, u_cyanOuter, 0.71, KNOT_X, knotShift);
strand(col, hot, uv, u_time, 0.65, 0.51, 0.56, 8400.0, u_cyanInner, 0.19, KNOT_X, knotShift);
/* bottom pair — exact mirror of the top around y = 0.5 */
strand(col, hot, uv, u_time, 0.08, 0.45, 0.28, 5200.0, u_violetOuter, 0.63, KNOT_X, knotShift);
strand(col, hot, uv, u_time, 0.14, 0.465, 0.33, 6400.0, u_violetInner, 0.28, KNOT_X, knotShift);
strand(col, hot, uv, u_time, 0.30, 0.48, 0.41, 7200.0, u_cyanOuter, 0.87, KNOT_X, knotShift);
strand(col, hot, uv, u_time, 0.35, 0.49, 0.44, 8400.0, u_cyanInner, 0.50, KNOT_X, knotShift);
col += u_flare * hot;
/* the vanishing point's own burst: a tight white core, a soft bloom, and
a lens-flare star with one long horizontal spike — the reference
photo's own convergence silhouette */
float coreDot = exp(-dKnot * dKnot * 150.0);
float bloom = exp(-dKnot * dKnot * 20.0);
col += mix(u_cyanInner, u_flare, 0.6) * bloom * 0.45;
col += u_flare * coreDot * 1.2;
float ax = abs(Pk.x);
float ay = abs(Pk.y);
float d45a = abs(Pk.x - Pk.y) * 0.7071;
float d45b = abs(Pk.x + Pk.y) * 0.7071;
float spikeH = exp(-ay * ay * 1300.0) * exp(-ax * ax * 3.0);
float spikeV = exp(-ax * ax * 3000.0) * exp(-ay * ay * 14.0);
float spikeD1 = exp(-d45a * d45a * 1900.0) * exp(-dKnot * 4.0);
float spikeD2 = exp(-d45b * d45b * 1900.0) * exp(-dKnot * 4.0);
col += u_flare * (spikeH * 0.85 + spikeV * 0.35 + spikeD1 * 0.22 + spikeD2 * 0.22);
/* two faint sparkle points riding the same horizontal axis further out,
the way a real lens throws small secondary glints along its
brightest line */
vec2 g1 = Pk - vec2(0.22 * aspect, 0.0);
vec2 g2 = Pk - vec2(0.4 * aspect, 0.0);
col += u_cyanOuter * exp(-dot(g1, g1) * 900.0) * 0.16;
col += u_violetOuter * exp(-dot(g2, g2) * 1300.0) * 0.11;
/* the pointer's own grip: a little extra light at the vanishing point */
col += u_flare * grip * 0.3;
col += u_cyanInner * grip * hot * 0.3;
/* corner vignette */
vec2 corner = uv - 0.5;
float cvig = length(corner * vec2(1.0, 1.25));
col *= mix(1.0, 0.6, smoothstep(0.55, 1.1, 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.09), guardBand * u_guard);
col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.0 / 255.0);
gl_FragColor = vec4(col, 1.0);
}
`;
export interface KnotFieldProps {
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 KnotField({ className, guardSelector = null }: KnotFieldProps) {
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 SlipstreamField / 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: 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(_30%_46%_at_34%_50%,oklch(0.98_0.01_240)_0%,oklch(0.85_0.14_220_/_0.9)_14%,oklch(0.62_0.22_290_/_0.55)_34%,oklch(0.4_0.18_300_/_0.3)_55%,transparent_74%_),radial-gradient(_60%_70%_at_4%_10%,oklch(0.32_0.2_300_/_0.5)_0%,transparent_60%_),radial-gradient(_55%_65%_at_4%_90%,oklch(0.32_0.2_300_/_0.5)_0%,transparent_58%_),radial-gradient(_45%_40%_at_96%_50%,oklch(0.4_0.18_250_/_0.3)_0%,transparent_62%_),oklch(0.05_0.02_260)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-linear-gradient(_0deg,transparent_0_8px,oklch(0.7_0.16_260_/_0.14)_8px_10px_)] after:[-webkit-mask-image:radial-gradient(_55%_75%_at_34%_50%,transparent_6%,#000_40%,transparent_86%_)] after:[mask-image:radial-gradient(_55%_75%_at_34%_50%,transparent_6%,#000_40%,transparent_86%_)]" />
<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