Skip to content

IrisBackgroundsAperture

Aperture

Twenty near-black glass blades fanned around the centre like a camera aperture caught part-open, an iridescent lip walking once around the ring; the pointer spins it and swings the light.

ring

Aperture

Twenty raytraced glass blades fan around the centre of the frame and tilt out of the screen, like the leaves of a camera aperture caught part-open. The fan occludes itself — one ray per pixel, nearest of twenty ray/plane hits — so you read real depth through the overlaps. The faces stay almost black; the light lives on the bevels, a thin iridescent lip that walks violet → fuchsia → copper → azure once around the ring, so one side reads warm and the opposite side cool, converging on a lit hub.

With the cursor away the iris turns on its own at a slow constant rate under a fixed key light up and to the left. As the cursor enters, its horizontal position adds spin and swings the key light around the ring — the iridescent lip sweeps the blades to follow it — while vertical position tilts the camera. The grip eases in and out, so a cursor that leaves lets the iris settle back to its idle turn rather than snapping. The four rim hues come from the site's shader-role ramp, read live, so a theme change or a context restore picks them up.

  • warm-cool
  • iridescent
  • raytraced
Family
ring
Status
Available
Licence
Free

Install

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

Usage

Drop it straight into a page.

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

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">
      <ApertureField />
    </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 iris — twenty near-black glass blades fanned around the centre
 * of the frame and tilted out of the screen, like the leaves of a camera
 * aperture caught part-open. Each blade is raytraced (one ray per pixel,
 * nearest of twenty ray/plane hits), so the fan occludes itself and you read
 * real depth through the overlaps. The faces stay almost black; the light
 * lives on the bevels — a thin iridescent lip that walks violet → fuchsia →
 * copper → azure once around the ring, so one side reads warm and the
 * opposite side cool, converging on a lit hub.
 *
 * The pointer is real. With the cursor away, the iris turns on its own at a
 * slow constant rate and a fixed key light sits up and to the left. As the
 * cursor enters, its horizontal position adds spin and swings the key light
 * around the ring — the iridescent lip sweeps the blades to follow it — while
 * vertical position tilts the camera. The grip eases in and out (presence,
 * handled by `shader-surface`), so a cursor that leaves lets the iris settle
 * back to its idle turn rather than snapping.
 *
 * 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-aperturefield__floor`
 * underneath visible — a still radial composition in the same palette, never
 * a blank box.
 *
 * The four rim hues come from the site's shader-role ramp
 * (`--shader-role-2-a` violet, `--shader-role-2-b` fuchsia, `--shader-role-3-b`
 * copper, `--shader-role-1-b` azure) and the ground from `--iris-bg-sunken`,
 * read live via `colors` so a theme change or a context restore picks them
 * up. Every one has a fallback in `shader-surface.ts`, so a blocked token
 * readback still draws in the right colours.
 */
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_rimA;   /* --shader-role-2-a, violet   */
uniform vec3 u_rimB;   /* --shader-role-2-b, fuchsia  */
uniform vec3 u_rimC;   /* --shader-role-3-b, copper   */
uniform vec3 u_rimD;   /* --shader-role-1-b, azure    */
uniform vec3 u_ground; /* --iris-bg-sunken            */

#define NBLADES 20
#define PI 3.14159265

/* blade geometry, in world units */
const float RAD_MID  = 0.75;  /* spoke offset of a blade's centre from the hub */
const float HALF_LEN = 0.64;  /* blade half-length, along its own spoke        */
const float HALF_W   = 0.175; /* blade half-width                              */
const float TWIST    = 0.48;  /* how far each blade tilts out of the screen    */

mat2 r2(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }
vec3 rotX(vec3 p, float a) { p.yz = r2(a) * p.yz; return p; }
vec3 rotY(vec3 p, float a) { p.xz = r2(a) * p.xz; return p; }

/* the iridescent lip: a closed loop violet -> fuchsia -> copper -> azure -> violet */
vec3 lip(float t) {
  t = fract(t);
  vec3 c = mix(u_rimA, u_rimB, smoothstep(0.0,  0.30, t));
  c = mix(c, u_rimC, smoothstep(0.30, 0.58, t));
  c = mix(c, u_rimD, smoothstep(0.58, 0.82, t));
  c = mix(c, u_rimA, smoothstep(0.82, 1.0,  t));
  return c;
}

vec3 render(vec3 ro, vec3 rd, float roll, vec3 lightDir, float lightPhase, float glow) {
  float bestT = 1e9;
  float bU = 0.0, bV = 0.0, bPy = 0.0, bAz = 0.0;
  vec3  bN = vec3(0.0);
  bool  hit = false;

  for (int i = 0; i < NBLADES; i++) {
    float az = float(i) * (2.0 * PI / float(NBLADES)) + roll;
    float ca = cos(az), sa = sin(az);
    vec3  sp = vec3(ca, sa, 0.0);                    /* spoke: blade length axis */
    vec3  tg = vec3(-sa, ca, 0.0);                   /* in-screen width axis     */
    float tw = TWIST * clamp(ca * 2.6, -1.0, 1.0);   /* tilt, mirrored across x  */
    vec3  w  = tg * cos(tw) + vec3(0.0, 0.0, 1.0) * sin(tw);
    vec3  nrm = vec3(0.0, 0.0, cos(tw)) - tg * sin(tw);
    vec3  ctr = sp * RAD_MID;

    float denom = dot(rd, nrm);
    if (abs(denom) < 1e-4) continue;
    float t = dot(ctr - ro, nrm) / denom;
    if (t <= 0.05 || t >= bestT) continue;

    vec3  P = ro + rd * t;
    float u = dot(P - ctr, sp) / HALF_LEN;
    float v = dot(P - ctr, w)  / HALF_W;
    if (abs(u) < 1.0 && abs(v) < 1.0) {
      bestT = t; bU = u; bV = v; bPy = P.y; bAz = az; bN = nrm; hit = true;
    }
  }

  /* ground: near-black, with a faint warm core where the blades converge */
  float core = 1.0 - clamp(length(rd.xy) * 7.0, 0.0, 1.0);
  vec3 bg = u_ground * 0.22 + lip(lightPhase + 0.15) * core * core * 0.5;
  if (!hit) return bg;

  float notInner = smoothstep(-1.0, -0.45, bU);           /* fade edges at the hub end */
  float longEdge = smoothstep(0.42, 1.0, abs(bV)) * notInner;
  float tipEdge  = smoothstep(0.74, 1.0, abs(bU));
  float lipHi    = (smoothstep(0.87, 0.999, abs(bV))
                 +  smoothstep(0.93, 0.999, abs(bU))) * notInner;
  float fres     = pow(1.0 - abs(dot(rd, bN)), 3.5);

  float hue = bAz * (0.5 / PI) + lightPhase;
  vec3  rim = lip(hue);

  vec3  h    = normalize(lightDir - rd);
  float spec = pow(max(dot(bN, h), 0.0), 70.0);

  /* the face: a near-black glossy plane, lit toward the top of the frame */
  float faceLit = 0.28 + 0.72 * smoothstep(-1.4, 1.3, bPy);
  vec3 col = u_ground * 0.34 * faceLit;
  col += rim * 0.035 * faceLit;
  col += rim * fres * (0.10 + glow * 0.30) * faceLit;
  col += rim * (longEdge * 0.16 + tipEdge * 0.10);
  col += rim * lipHi * (0.60 + glow * 0.70);
  col += mix(rim, vec3(1.0), 0.40) * spec * 0.80 * faceLit;

  col *= mix(1.0, 0.46, smoothstep(3.6, 6.6, bestT));      /* far blades recede */
  return col;
}

void main() {
  vec2 res = u_res / u_scale;
  vec2 fc  = gl_FragCoord.xy / u_scale;

  float pres  = u_pointer.z;
  float yaw   = (u_pointer.x - 0.5) * 0.50 * pres;
  float pitch = 0.05 + (u_pointer.y - 0.5) * 0.22 * pres;
  float roll  = u_time * 0.012 + (u_pointer.x - 0.5) * 0.12 * pres;

  /* key light: up and to the left by default; the cursor swings it around
     the ring (x) and lifts or drops it (y) */
  vec3 lightDir = normalize(vec3(-0.45, 0.62, 0.64));
  lightDir = rotY(lightDir, (u_pointer.x - 0.5) * PI * (0.35 + 0.65 * pres));
  lightDir = rotX(lightDir, (u_pointer.y - 0.5) * 0.55 * pres);
  float lightPhase = u_time * 0.028 + (u_pointer.x - 0.5) * 0.5 * pres;
  float glow = 0.14 + 0.86 * pres;

  /* 2x2 supersample — the blade silhouettes are thin and bright against
     black, so a single sample crawls badly along their edges */
  vec3 acc = vec3(0.0);
  for (int sx = 0; sx < 2; sx++) {
    for (int sy = 0; sy < 2; sy++) {
      vec2 o = (vec2(float(sx), float(sy)) + 0.25) * 0.5 - 0.5;
      vec2 p = (fc + o - 0.5 * res) / res.y;

      vec3 ro = vec3(0.0, 0.0, 6.6);
      vec3 rd = normalize(vec3(p, -2.1));
      ro = rotX(ro, pitch); rd = rotX(rd, pitch);
      ro = rotY(ro, yaw);   rd = rotY(rd, yaw);

      acc += render(ro, rd, roll, lightDir, lightPhase, glow);
    }
  }
  vec3 col = max(acc * 0.25, 0.0);

  col += (bayer8(gl_FragCoord.xy) - 0.5) * (2.5 / 255.0);
  gl_FragColor = vec4(col, 1.0);
}
`;

export interface ApertureFieldProps {
  className?: string;
}

export function ApertureField({ className }: ApertureFieldProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    return mountShaderSurface(canvas, {
      fragment: FRAG,
      colors: {
        u_rimA: "--shader-role-2-a",
        u_rimB: "--shader-role-2-b",
        u_rimC: "--shader-role-3-b",
        u_rimD: "--shader-role-1-b",
        u_ground: "--iris-bg-sunken",
      },
      onPainted: () => canvas.setAttribute("data-shader", "on"),
      /* No onIdle: the last frame stays on screen while the surface is
         parked off-view. onLost drops back to the CSS floor. */
      onLost: () => canvas.removeAttribute("data-shader"),
      maxPixels: 1_300_000,
      dprCap: 1.5,
    });
  }, []);

  return (
    <div
      className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
      aria-hidden="true"
    >
      <div className="absolute inset-0 [background:oklch(0.115_0.012_285)] before:content-[''] before:absolute before:[inset:50%_auto_auto_50%] before:[width:min(72vh,92%)] before:[aspect-ratio:1] before:[transform:translate(-50%,-50%)] before:[border-radius:50%] before:[background:repeating-conic-gradient(_from_-99deg_at_50%_50%,oklch(0.6_0.24_315_/_0.5)_0deg_1.6deg,transparent_1.6deg_18deg_),conic-gradient(_from_90deg_at_50%_50%,oklch(0.74_0.17_45_/_0.55)_0deg,oklch(0.6_0.24_315_/_0.55)_130deg,oklch(0.62_0.18_235_/_0.55)_215deg,oklch(0.74_0.17_45_/_0.55)_360deg_)] before:[background-blend-mode:screen] before:[-webkit-mask-image:radial-gradient(_circle_at_50%_50%,#000_6%,#000_88%,transparent_100%_)] before:[mask-image:radial-gradient(_circle_at_50%_50%,#000_6%,#000_88%,transparent_100%_)] after:content-[''] after:absolute after:inset-0 after:[background:radial-gradient(_34%_40%_at_50%_50%,oklch(0.55_0.19_40_/_0.35)_0%,transparent_70%_)]" />
      <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