IrisBackgroundsPrism Veil
Prism Veil
Thin vertical bars of colour along one continuous spectral sweep — void, maroon, coral, cream, mint, rose, ice, dusk — streaked as if photographed with the shutter dragged straight down; the pointer combs a reach of bars into sharp focus.
Prism Veil
One long spectral sweep runs left to right in narrow vertical bars rather than a flat gradient — void into maroon into coral into a bright cream peak, cooling through mint and a dustier rose before settling through ice and dusk back toward void. Each bar carries its own fine grain and a slight vertical streak, the anisotropic blur of a long exposure dragged straight down rather than a printed stripe, and a handful fall to near-black or lift to near-white at random, the way a real exposure loses a column to shadow or blows one out.
The pointer pulls focus: a wide, soft reach of bars around the cursor sharpens — their streak relaxes, their contrast lifts — while the rest stays in its own gentle blur, and the nearby bars comb sideways away from the touch as if the bundle were being parted by hand. A soft cream bloom rides wherever it rests. Left alone, the whole sweep still drifts sideways on its own, slowly, so it is never a static swatch.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { PrismField } from "./PrismField";
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">
<PrismField />
</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 long-exposure prism light — thin vertical bars of
* colour, each a slightly different hue along one continuous spectral sweep
* (void, maroon, coral, cream, mint, rose, ice, dusk), streaked and softened
* as if photographed with the shutter dragged straight down rather than
* printed as flat stripes. A handful of bars fall to near-black or lift to
* near-white at random, the way a real long exposure loses a column to
* shadow or blows one out, and the whole sweep drifts sideways on its own
* very slowly.
*
* Interactive: the pointer pulls focus — bars within its reach sharpen
* (their streak-blur relaxes, contrast lifts) and lean away from the cursor
* as if the bundle were being combed apart by hand, with a soft cream bloom
* left where it touched. Move away and the field eases back to its own
* soft, drifting blur.
*
* One of the reusable background fields (`GauzeField`, `SlipstreamField`,
* `MeshFluidField`, …). 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-prismfield__floor` underneath visible — a still frame in the same
* palette, never a blank box.
*
* Like `GauzeField` and `SlipstreamField`, the palette is a fixed set of
* uniforms rather than read from the accent ramp — this particular pastel
* spectrum lives nowhere in the site's own ramp.
*
* 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. One
continuous sweep, void to void, through six named stops between. */
const PALETTE: Record<string, [number, number, number]> = {
u_void: [0.03, 0.024, 0.026], // the shadowed bars, and the outer edges
u_maroon: [0.34, 0.1, 0.09], // the first warmth rising out of the void
u_coral: [0.86, 0.46, 0.38], // the widest warm band
u_cream: [0.94, 0.87, 0.74], // the brightest bar in the sweep
u_mint: [0.44, 0.68, 0.58], // the cool turn past the cream
u_rose: [0.82, 0.56, 0.56], // a second, dustier warmth
u_ice: [0.58, 0.65, 0.75], // the coolest bar, and the pointer's sharpened fringe
u_dusk: [0.16, 0.15, 0.22], // where the sweep settles back toward the void
};
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_void;
uniform vec3 u_maroon;
uniform vec3 u_coral;
uniform vec3 u_cream;
uniform vec3 u_mint;
uniform vec3 u_rose;
uniform vec3 u_ice;
uniform vec3 u_dusk;
uniform vec4 u_readA;
uniform float u_guard;
const float BANDS = 30.0;
/* The sweep itself: void → maroon → coral → cream → mint → rose → ice →
dusk, each transition its own soft smoothstep so no seam reads as a hard
edge — a spectrum, not a swatch chart. */
vec3 sweep(float t) {
t = fract(t);
vec3 col = mix(u_void, u_maroon, smoothstep(0.0, 0.14, t));
col = mix(col, u_coral, smoothstep(0.10, 0.28, t));
col = mix(col, u_cream, smoothstep(0.26, 0.42, t));
col = mix(col, u_mint, smoothstep(0.40, 0.56, t));
col = mix(col, u_rose, smoothstep(0.54, 0.70, t));
col = mix(col, u_ice, smoothstep(0.68, 0.84, t));
col = mix(col, u_dusk, smoothstep(0.82, 0.96, t));
col = mix(col, u_void, smoothstep(0.94, 1.0, t));
return col;
}
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);
vec2 ptr = (u_pointer.xy - 0.5) * vec2(aspect, 1.0);
vec2 toP = P - ptr;
float dP = length(toP);
/* wide, soft radius — a whole reach of bars pulls into focus at once,
never one isolated column */
float focus = u_pointer.z * exp(-dP * dP * 2.0);
/* the pointer combs the bars apart sideways, away from itself */
float parted = uv.x + (-toP.x) * focus * 0.1;
float drift = u_time * 0.014;
/* anisotropic streak noise: high frequency across x, low across y, so the
grain elongates into vertical streaks rather than reading as a texture
— the long-exposure blur the reference photo carries. Relaxes (more
jitter, softer) outside the pointer's reach, sharpens inside it. */
float jitterAmt = mix(0.052, 0.012, focus);
float streak = fbm(vec2(parted * 5.5 + drift * 3.0, uv.y * 0.6));
float t = parted * 1.35 + streak * jitterAmt + drift;
vec3 col = sweep(t);
/* a handful of whole bars pulled to near-black or lifted to near-white,
the way a real long exposure loses a column to shadow or blows one out */
float bandId = floor(parted * BANDS);
float bandHash = dotHash(vec2(bandId, 4.1));
float darkBand = smoothstep(0.92, 0.965, bandHash);
float liteBand = smoothstep(0.955, 0.99, fract(bandHash * 6.53 + 0.5));
col = mix(col, u_void * 0.55, darkBand * 0.8);
col = mix(col, u_ice, liteBand * 0.55);
/* fine per-column grain — brighter and darker streaks inside each bar,
not a flat fill. Quiets inside the focus radius, where things sharpen
rather than shimmer. */
float grain = fbm(vec2(parted * 24.0, uv.y * 1.3 + drift * 2.0));
col *= 1.0 + grain * mix(0.15, 0.05, focus);
/* pulled into focus: contrast lifts and the streak relaxes, as if a lens
had just found this stretch of the sweep */
col = mix(col, (col - 0.5) * 1.3 + 0.5, focus * 0.55);
/* soft top/bottom vignette, echoing the reference photo's darker corners */
float vig = smoothstep(0.98, 0.3, abs(uv.y - 0.5) * 2.0);
col *= mix(0.68, 1.0, vig);
/* the pointer's own warm bloom, where the bundle is being touched */
col += u_cream * focus * 0.2;
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.2 / 255.0);
gl_FragColor = vec4(col, 1.0);
}
`;
const PALETTE_UNIFORMS = [
"u_void",
"u_maroon",
"u_coral",
"u_cream",
"u_mint",
"u_rose",
"u_ice",
"u_dusk",
] as const;
export interface PrismFieldProps {
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 PrismField({ className, guardSelector = null }: PrismFieldProps) {
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: [...PALETTE_UNIFORMS, "u_readA", "u_guard"],
onInit: (gl, u) => {
for (const name of PALETTE_UNIFORMS) {
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 GauzeField / SlipstreamField: 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_200_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%_90%_at_50%_0%,transparent_62%,oklch(0.05_0.01_30_/_0.55)_100%_),radial-gradient(_120%_90%_at_50%_100%,transparent_62%,oklch(0.05_0.01_30_/_0.55)_100%_),linear-gradient(_90deg,oklch(0.05_0.01_30)_0%,oklch(0.3_0.09_25)_14%,oklch(0.72_0.13_35)_28%,oklch(0.92_0.04_80)_41%,oklch(0.68_0.08_165)_55%,oklch(0.72_0.08_15)_68%,oklch(0.66_0.05_245)_81%,oklch(0.22_0.04_280)_93%,oklch(0.05_0.01_30)_100%_)] after:content-[''] after:absolute after:inset-0 after:[background:repeating-linear-gradient(_90deg,oklch(0.05_0.01_30_/_0.12)_0_2px,transparent_2px_9px,oklch(1_0_0_/_0.05)_9px_11px,transparent_11px_18px_)]" />
<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