Skip to content

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.

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.

Details

Kind
Ring
Stack
React · TypeScript
Depends on
Nothing
Licence
Free
Added
11 Sep 2026
Tags
warm-cool, iridescent, raytraced

Its light

  • 2%
  • 72%
  • 11%
  • 10%
  • 5%

Code

Install

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

Usage

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>
  );
}

Nearest in light

More ring 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