Skip to content

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

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.

  • pastel
  • pointer-driven
  • spectral
  • film-grain
Family
prism
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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