Photoreal Pricing Table with Monthly Yearly Switch and Three Glass Blocks of Different Heights in One Shaft of Window Light

A complete three-plan pricing table for a SaaS page, topped by a photograph-like scene. Three blocks of thick, slightly green glass stand on honed limestone in front of a cobalt limewashed wall, short, medium and tall, so the size of each plan reads before any number does. Each block has its plan name and price sandblasted into the front face. A narrow window behind and to the left lets in one shaft of low sun; it lands on the chosen plan, passes through the glass and throws bright bands of light onto the stone toward you. Choose another plan and the shaft slides along the row. Flip the billing switch and the frosted prices on every block fade over to the yearly figures. Under the scene sits the usable part: three plan cards with the price, what it is billed as, a one-line pitch, a feature list and a call-to-action link, all real HTML. The scene is one fragment shader that averages many jittered frames into a clean still and then stops drawing. Prices shown are examples.

Code

import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";

/** The three plans. Both prices are per month: `yearly` is the monthly rate when paid once a year. Example prices. */
const plans = [
  {
    name: "Starter",
    monthly: 9,
    yearly: 7,
    pitch: "For one person shipping their first forms.",
    features: ["3 live forms", "1,000 responses a month", "Email alerts on every reply", "Export to CSV"],
    cta: "Start with Starter",
    height: 8, // height of the glass block, in centimetres
  },
  {
    name: "Team",
    monthly: 24,
    yearly: 19,
    pitch: "Shared forms and answers for a small team.",
    features: ["Unlimited forms", "10,000 responses a month", "5 seats with roles", "Logic, branching and file uploads", "Remove the footer badge"],
    cta: "Start with Team",
    height: 12,
  },
  {
    name: "Scale",
    monthly: 59,
    yearly: 47,
    pitch: "Volume, control and a person to call.",
    features: ["Everything in Team", "100,000 responses a month", "25 seats and single sign-on", "Audit log and data residency", "Priority support"],
    cta: "Talk to us",
    height: 17,
  },
];

const PITCH = 10.5; // centimetres between block centres
const BW = 3.4; // half the width of a block (cm)
const BD = 2.4; // half its depth (cm)
const POOL_SPRING = 70; // pull of the pool of sun toward the chosen block
const POOL_DAMPING = 13; // lower lets the light overshoot
const DEMO = true; // walks through yearly and the plans by itself until somebody touches it
const KEY: [number, number, number] = [-0.42, 0.5, -0.76]; // toward the window: behind and to the left
const GROTESK = '"Inter Tight", "Inter", ui-sans-serif, system-ui, sans-serif';

/* ---- photoreal kit (pasted, see PHOTOREAL-KIT.md) ---- */
const FULLSCREEN_VS = `#version 300 es
out vec2 vUv;
void main() { vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); vUv = p; gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); }`;
const GLSL_HEADER = `#version 300 es
precision highp float;
uniform vec2 uRes;      // drawing buffer size in pixels
uniform float uFrame;   // sample index since the last reset (drives every random number)
uniform float uTime;    // seconds, for things that really move
uniform vec3 uCamPos, uCamRt, uCamUp, uCamFw;
uniform float uTan, uAspect, uAperture, uFocus;
uniform vec2 uShift;    // lens shift (architectural framing): move the subject in frame without tilting verticals
out vec4 o;`;
const GLSL_COMMON = `
#define PI 3.14159265359
float saturate(float x) { return clamp(x, 0.0, 1.0); }
float hash12(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
float hash13(vec3 p3) { p3 = fract(p3 * 0.1031); p3 += dot(p3, p3.zyx + 31.32); return fract((p3.x + p3.y) * p3.z); }
float ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); }
float vnoise(vec2 p) {
  vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash12(i), hash12(i + vec2(1, 0)), f.x), mix(hash12(i + vec2(0, 1)), hash12(i + vec2(1, 1)), f.x), f.y);
}
float vnoise3(vec3 p) {
  vec3 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
  return mix(mix(mix(hash13(i), hash13(i + vec3(1, 0, 0)), f.x), mix(hash13(i + vec3(0, 1, 0)), hash13(i + vec3(1, 1, 0)), f.x), f.y),
             mix(mix(hash13(i + vec3(0, 0, 1)), hash13(i + vec3(1, 0, 1)), f.x), mix(hash13(i + vec3(0, 1, 1)), hash13(i + vec3(1, 1, 1)), f.x), f.y), f.z);
}
float fbm(vec2 p) { float a = 0.5, s = 0.0; for (int i = 0; i < 5; i++) { s += a * vnoise(p); p = mat2(1.6, 1.2, -1.2, 1.6) * p + 3.1; a *= 0.5; } return s; }
float fbm3(vec3 p) { float a = 0.5, s = 0.0; for (int i = 0; i < 4; i++) { s += a * vnoise3(p); p = p * 2.03 + vec3(1.7, 9.2, 3.1); a *= 0.5; } return s; }
// Clamp one sample before it is averaged: a single lucky glossy ray hitting the window would otherwise leave
// a bright speck ("firefly") that takes hundreds of frames to fade. Call on the final colour in main().
vec3 clampSample(vec3 c, float m) { float l = max(c.r, max(c.g, c.b)); return l > m ? c * (m / l) : c; }
// A new well-spread 2D random point per pixel AND per frame: the R2 low-discrepancy sequence over frames,
// rotated by a per-pixel white-noise offset (structured offsets such as ign() show diagonal hatching while moving).
// Use a different salt for every decision (pixel jitter, lens, sun disc, gloss...). Accumulation averages them.
vec2 rand2(float salt) {
  vec2 f = gl_FragCoord.xy + salt * 17.13;
  return fract(vec2(hash12(f), hash12(f + 71.7)) + uFrame * vec2(0.7548776662, 0.5698402910));
}`;
const GLSL_SAMPLING = `
mat3 basisOf(vec3 n) { vec3 t = abs(n.y) < 0.999 ? normalize(cross(vec3(0.0, 1.0, 0.0), n)) : vec3(1.0, 0.0, 0.0); return mat3(t, cross(n, t), n); }
vec2 diskSample(vec2 u) { float r = sqrt(u.x), a = 6.2831853 * u.y; return r * vec2(cos(a), sin(a)); }
vec3 cosineSample(vec3 n, vec2 u) { vec2 d = diskSample(u); return basisOf(n) * vec3(d, sqrt(max(0.0, 1.0 - dot(d, d)))); }
// a microfacet normal from the GGX distribution: reflect about it for a rough reflection, refract through it for frosted glass
vec3 ggxSample(vec3 n, float rough, vec2 u) {
  float a = rough * rough;
  float ct = sqrt((1.0 - u.x) / (1.0 + (a * a - 1.0) * u.x)), st = sqrt(max(0.0, 1.0 - ct * ct)), ph = 6.2831853 * u.y;
  return normalize(basisOf(n) * vec3(st * cos(ph), st * sin(ph), ct));
}`;
const GLSL_CAMERA = `
// Thin-lens camera. Sub-pixel jitter = supersampling; a random point on the lens = true depth of field.
// uAperture is the lens radius in scene units (0 = pinhole). uFocus is the distance along the view axis that is sharp.
void cameraRay(out vec3 ro, out vec3 rd) {
  vec2 px = rand2(1.0) - 0.5;
  vec2 ndc = (gl_FragCoord.xy + px) / uRes * 2.0 - 1.0 - uShift;
  vec3 dir = normalize(uCamFw + uCamRt * (ndc.x * uTan * uAspect) + uCamUp * (ndc.y * uTan));
  vec3 fp = uCamPos + dir * (uFocus / dot(dir, uCamFw));
  vec2 lens = diskSample(rand2(2.0)) * uAperture;
  ro = uCamPos + uCamRt * lens.x + uCamUp * lens.y;
  rd = normalize(fp - ro);
}`;
// the light rig, edited for this scene: a tall narrow window behind the blocks; the pool of sun follows the chosen plan
const GLSL_RIG_SETTINGS = `
uniform vec2 uWin;
const vec3 KEY_DIR = normalize(vec3(${KEY.join(", ")}));
const vec3 KEY_COL = vec3(1.0, 0.84, 0.65) * 3.8;
const float KEY_SPREAD = 0.006;
const float WIN_DIST = 160.0;
const vec2 WIN_HALF = vec2(6.0, 40.0);
#define WIN_OFF uWin
const float BAR = 0.4;
const float BAR_Y = -9.0;
const vec3 SKY_COL = vec3(0.55, 0.64, 0.8) * 0.14;
const vec3 FILL_DIR = normalize(vec3(0.3, 0.42, 1.0));
const vec3 FILL_COL = vec3(1.0, 0.95, 0.88) * 0.1;
const vec3 CEIL_COL = vec3(0.95, 0.9, 0.84) * 0.45;`;
const GLSL_LIGHT_RIG = `
// the window opening with one vertical bar and one transom, in window-plane units
float paneMask(vec2 q, float pen) {
  vec2 e = WIN_HALF - abs(q);
  float open = smoothstep(-pen, pen, e.x) * smoothstep(-pen, pen, e.y);
  float bars = smoothstep(-pen, pen, abs(q.x) - BAR) * smoothstep(-pen, pen, abs(q.y - BAR_Y) - BAR);
  return open * bars;
}
// The gobo: how much of the window a point sees along KEY_DIR. This is what makes light look like it came
// from a real room instead of a CG directional light: a pool with straight edges and bar shadows.
float keyGobo(vec3 p) {
  mat3 B = basisOf(KEY_DIR);
  vec2 q = vec2(dot(p, B[0]), dot(p, B[1])) - WIN_OFF;
  float d = max(WIN_DIST - dot(p, KEY_DIR), 1.0);
  return paneMask(q, 0.25 + d * KEY_SPREAD);
}
// What a mirror sees. The window appears as a bright shape with bars: that shape in every reflection is the
// single strongest "this is a photograph" cue. Everything else is dark so reflections have contrast.
// envStudioRough: the same room seen in a ROUGH surface. Instead of firing random glossy rays at a small, very
// bright window (the source of sand-like speckle and sparkling metal), the window and card are blurred
// analytically by the roughness. Deterministic: zero noise, converges in one frame.
vec3 envStudioRough(vec3 rd, float rough) {
  float a = rough * rough;                       // GGX alpha: roughly the angular blur in radians
  vec3 c = mix(vec3(0.028, 0.025, 0.022), CEIL_COL, smoothstep(0.1 - a, 0.95 + a, rd.y));
  c = mix(c, vec3(0.09, 0.075, 0.06), smoothstep(0.0, -0.5 - a, rd.y));
  float k = dot(rd, KEY_DIR);
  if (k > 0.0) {
    mat3 B = basisOf(KEY_DIR);
    vec2 q = vec2(dot(rd, B[0]), dot(rd, B[1])) / k * WIN_DIST - WIN_OFF;
    float pen = 1.2 + a * WIN_DIST;
    // a blurred window spreads the same light over a larger area: keep its energy, so the peak drops
    float spread = (WIN_HALF.x * WIN_HALF.y) / ((WIN_HALF.x + pen) * (WIN_HALF.y + pen));
    c += vec3(1.0, 0.96, 0.9) * 26.0 * paneMask(q, pen) * mix(1.0, spread, saturate(a * 4.0));
  }
  float f = dot(rd, FILL_DIR);
  if (f > 0.0) {
    mat3 B = basisOf(FILL_DIR);
    vec2 e = vec2(0.6, 0.36) - abs(vec2(dot(rd, B[0]), dot(rd, B[1])) / f);
    float w = 0.18 + a;
    c += FILL_COL * 7.0 * smoothstep(-w, w, e.x) * smoothstep(-w, w, e.y);
  }
  return c;
}
vec3 envStudio(vec3 rd) { return envStudioRough(rd, 0.0); }`;
const GLSL_MATERIALS = `
// q = position in the object's own frame, in CENTIMETRES (scale noise accordingly if your units differ).
// n = geometric normal. Every material returns a perturbed normal: real surfaces are never perfect.
struct Mat { vec3 albedo; float rough; float metal; float coat; vec3 n; };
Mat mk(vec3 a, float r, float m, float c, vec3 n) { Mat x; x.albedo = a; x.rough = r; x.metal = m; x.coat = c; x.n = n; return x; }

// Metals: these are reflectance (F0) colours in linear light, not paint colours.
const vec3 BRASS = vec3(0.88, 0.69, 0.37);
const vec3 GOLD = vec3(1.0, 0.76, 0.33);
const vec3 STEEL = vec3(0.56, 0.56, 0.54);
const vec3 ALUMINIUM = vec3(0.91, 0.92, 0.92);
const vec3 COPPER = vec3(0.95, 0.62, 0.50);

// Brushed: fine parallel scratches along axis. Tilting the normal ACROSS the brushing stretches highlights
// across it, which is how brushed metal really looks.
Mat matBrushed(vec3 q, vec3 n, vec3 f0, vec3 axis) {
  vec3 b = normalize(cross(n, axis) + 1e-4);
  float a = dot(q, axis), c = dot(q, b);
  float s = 0.6 * vnoise(vec2(a * 0.5, c * 240.0)) + 0.4 * vnoise(vec2(a * 0.1, c * 700.0));
  return mk(f0 * (0.96 + 0.04 * s), 0.24 + 0.04 * s, 1.0, 0.0, normalize(n + b * (s - 0.5) * 0.012));
}
// Polished: near mirror, with faint handling haze in patches (no real polished object is uniform).
Mat matPolished(vec3 q, vec3 n, vec3 f0) {
  float h = fbm(q.xz * 0.9 + q.y);
  return mk(f0, 0.04 + 0.09 * smoothstep(0.45, 0.75, h), 1.0, 0.0, n);
}
// Glazed ceramic: coloured body under a glass coat; glaze pools and thins, a few pinholes, gentle waviness.
Mat matCeramic(vec3 q, vec3 n, vec3 glaze) {
  float pool = fbm(q.xz * 0.6 + q.y * 0.4);
  float pin = step(0.996, hash13(floor(q * 22.0)));
  vec3 w = vec3(fbm(q.xy * 1.3) - 0.5, 0.0, fbm(q.zy * 1.3 + 4.0) - 0.5);
  return mk(glaze * (0.78 + 0.4 * pool) * (1.0 - 0.6 * pin), 0.35, 0.0, 1.0, normalize(n + w * 0.025));
}
// Matte paper or card: fibres, never flat white.
Mat matPaper(vec3 q, vec3 n, vec3 tint) {
  float f = fbm(q.xz * 9.0) * 0.6 + vnoise(q.xz * 60.0) * 0.4;
  return mk(tint * (0.93 + 0.08 * f), 0.88, 0.0, 0.0, normalize(n + vec3(f - 0.5, 0.0, f - 0.5) * 0.02));
}
// Linen: plain weave with slubs (thicker uneven threads), about 18 threads per cm.
Mat matLinen(vec3 q, vec3 n, vec3 tint) {
  vec2 u = q.xz * 18.0;
  float wx = 0.5 + 0.5 * sin(u.x * PI), wy = 0.5 + 0.5 * sin(u.y * PI);
  float over = mod(floor(u.x) + floor(u.y), 2.0);
  float th = mix(wx, wy, over);
  float slub = vnoise(vec2(floor(u.x) * 3.1, u.y * 0.08)) * vnoise(vec2(u.x * 0.08, floor(u.y) * 2.7));
  vec3 a = tint * (0.8 + 0.2 * th) * (0.92 + 0.16 * slub);
  return mk(a, 0.9, 0.0, 0.0, normalize(n + vec3(cos(u.x * PI) * (1.0 - over), 0.0, cos(u.y * PI) * over) * 0.08));
}
// Plaster / limewash: broad trowel clouds and fine pores.
Mat matPlaster(vec3 q, vec3 n, vec3 tint) {
  float c = fbm(q.xy * 0.18 + q.z * 0.1), p = vnoise(q.xy * 35.0 + q.z);
  return mk(tint * (0.86 + 0.22 * c) * (0.97 + 0.04 * p), 0.92, 0.0, 0.0, normalize(n + vec3(p - 0.5, c - 0.5, 0.0) * 0.03));
}
// Honed travertine: cream with soft bands along one direction and long shallow pits.
Mat matTravertine(vec3 q, vec3 n) {
  float band = fbm(vec2(q.z * 0.35 + fbm(q.xz * 0.08) * 2.0, q.x * 0.03));
  vec3 a = mix(vec3(0.66, 0.58, 0.47), vec3(0.78, 0.71, 0.6), band);
  float pitN = vnoise(vec2(q.x * 1.6, q.z * 9.0) + 7.0) * vnoise(vec2(q.x * 0.5, q.z * 3.0));
  float pit = smoothstep(0.5, 0.58, pitN);
  a = mix(a, vec3(0.52, 0.44, 0.34), pit * 0.3);
  a *= 0.96 + 0.08 * vnoise(q.xz * 30.0);
  return mk(a, mix(0.42, 0.9, pit), 0.0, 0.0, normalize(n + vec3(0.0, 0.0, pit * 0.08)));
}
// Marble: soft warped veins, polished.
Mat matMarble(vec3 q, vec3 n, vec3 base, vec3 vein) {
  // thin veins that wander but do not curl: low warp, a main vein family and a fainter hairline family
  float w = fbm3(q * 0.15) * 3.2;
  float v = 1.0 - smoothstep(0.0, 0.05, abs(sin(q.x * 0.35 + q.z * 0.18 + w)));
  float hair = 1.0 - smoothstep(0.0, 0.03, abs(sin(q.x * 0.9 - q.z * 0.6 + w * 1.7)));
  vec3 a = mix(base, vein, v * 0.6 + hair * 0.2) * (0.96 + 0.08 * fbm(q.xz * 1.5));
  return mk(a, 0.12, 0.0, 0.6, n);
}
// Wood, grain running along x. Photographic wood is LOW contrast: rings within 25% brightness, not stripes.
const vec3 OAK_LIGHT = vec3(0.52, 0.36, 0.20), OAK_DARK = vec3(0.36, 0.23, 0.12);
const vec3 WALNUT_LIGHT = vec3(0.20, 0.11, 0.06), WALNUT_DARK = vec3(0.11, 0.055, 0.03);
Mat matWood(vec3 q, vec3 n, vec3 light, vec3 dark, float pores) {
  float r = length(q.yz + vec2(9.0, 4.0)) * 3.2 + fbm3(q * vec3(0.05, 0.7, 0.7)) * 3.0;
  float ring = smoothstep(0.4, 0.95, fract(r));
  float streak = vnoise(vec2(q.x * 0.05, q.z * 11.0 + q.y * 11.0)) * 0.6 + vnoise(vec2(q.x * 0.2, q.z * 40.0)) * 0.4;
  vec3 a = mix(light, dark, ring * 0.3 + streak * 0.35);
  float pore = smoothstep(0.78, 0.86, vnoise(vec2(q.x * 1.4, (q.z + q.y) * 55.0))) * pores;
  a *= 1.0 - pore * 0.35;
  return mk(a, 0.45 + 0.2 * pore, 0.0, 0.35, normalize(n + vec3(0.0, 0.0, (streak - 0.5) * 0.03)));
}

// Dielectrics (glass, acrylic, resin). You trace the refracted ray yourself; these do the physics around it.
float fresnelD(float c, float ior) { float r = (1.0 - ior) / (1.0 + ior); r *= r; return r + (1.0 - r) * pow(1.0 - saturate(c), 5.0); }
// Light lost to the colour of the body over a path of length len. absorb is per unit length per channel:
// clear soda glass vec3(0.05, 0.012, 0.03) per cm (green edges); resin uses strong values for deep colour.
vec3 beer(vec3 absorb, float len) { return exp(-absorb * len); }`;
const GLSL_SDF = `
float sdRoundBox(vec3 p, vec3 b, float r) { vec3 q = abs(p) - b + r; return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - r; }
float smax(float a, float b, float k) { float h = max(k - abs(a - b), 0.0); return max(a, b) + h * h * 0.25 / k; }
float smin(float a, float b, float k) { float h = max(k - abs(a - b), 0.0); return min(a, b) - h * h * 0.25 / k; }
// ray vs box centred at the origin: (t near, t far)
vec2 boxI(vec3 ro, vec3 rd, vec3 b) {
  rd = mix(rd, vec3(1e-5), lessThan(abs(rd), vec3(1e-5)));
  vec3 m = 1.0 / rd, n = m * ro, k = abs(m) * b;
  vec3 t1 = -n - k, t2 = -n + k;
  return vec2(max(max(t1.x, t1.y), t1.z), min(min(t2.x, t2.y), t2.z));
}
vec3 boxFaceNormal(vec3 p, vec3 b) { vec3 d = abs(p) / b; return d.x > d.y && d.x > d.z ? vec3(sign(p.x), 0, 0) : d.y > d.z ? vec3(0, sign(p.y), 0) : vec3(0, 0, sign(p.z)); }`;
const GLSL_SHADOW_AO = `
// needs: const float SCENE_TOP, AO_RADIUS; float map(vec3); float mapShadow(vec3); vec3 lightFilter(vec3 p, vec3 l)
// Deterministic penumbra (no random sun points: random shadow rays are the main source of floor speckle).
// SHADOW_SOFT is the penumbra angle in radians: sun-sharp is ~0.01, overcast-soft ~0.08.
const float SHADOW_SOFT = 0.03;
float softShadow(vec3 ro, vec3 rd, float tmin, float tmax) {
  float res = 1.0, t = tmin, ph = 1e10;
  for (int i = 0; i < 48; i++) {
    vec3 p = ro + rd * t;
    if (p.y > SCENE_TOP && rd.y > 0.0) break;
    float h = mapShadow(p);
    if (h < 0.0004) return 0.0;
    float y = h * h / (2.0 * ph), d = sqrt(max(h * h - y * y, 0.0));
    res = min(res, d / (SHADOW_SOFT * max(1e-3, t - y)));
    ph = h;
    t += clamp(h, 0.01, 3.0);
    if (t > tmax) break;
  }
  res = saturate(res);
  return res * res * (3.0 - 2.0 * res);
}
vec3 shadowCol(vec3 ro, vec3 rd) { return softShadow(ro, rd, 0.01, 200.0) * lightFilter(ro, rd); }
float calcAO(vec3 p, vec3 n) {
  float occ = 0.0, s = 1.0;
  for (int i = 0; i < 5; i++) { float h = AO_RADIUS * (0.06 + 0.94 * float(i) / 4.0); occ += (h - map(p + n * h)) * s; s *= 0.72; }
  return saturate(1.0 - 1.4 * occ / AO_RADIUS);
}
vec3 calcNormal(vec3 p, float e) {
  vec2 k = vec2(1.0, -1.0);
  return normalize(k.xyy * map(p + k.xyy * e) + k.yyx * map(p + k.yyx * e) + k.yxy * map(p + k.yxy * e) + k.xxx * map(p + k.xxx * e));
}`;
const GLSL_LIGHTING = `
// One opaque surface under the rig. Diffuse from the key (through the window gobo, with smooth analytic
// shadows), cool sky and the card; specular reflects the room along the mirror direction with the window
// pre-blurred by roughness (envStudioRough). Everything here is deterministic, so it adds NO per-frame noise:
// accumulation only has to clean up edges, depth of field and any glass you trace yourself.
// coat adds a sharp glaze/lacquer layer.
vec3 litOpaque(vec3 p, vec3 v, Mat m, float ao) {
  vec3 n = m.n;
  float NoV = max(dot(n, v), 1e-3);
  vec3 L = KEY_DIR;
  float NoL = max(dot(n, L), 0.0);
  vec3 sun = NoL > 0.0 ? KEY_COL * NoL * keyGobo(p) * shadowCol(p + n * 0.015, L) : vec3(0.0);
  vec3 f0 = mix(vec3(0.04), m.albedo, m.metal);
  vec3 F = f0 + (1.0 - f0) * pow(1.0 - NoV, 5.0);
  vec3 amb = SKY_COL * (0.55 + 0.45 * n.y) + FILL_COL * 1.6 * max(dot(n, FILL_DIR) + 0.25, 0.0);
  vec3 col = (1.0 - F) * (1.0 - m.metal) * m.albedo * (sun + amb * ao);
  vec3 r = reflect(-v, n);
  // rough surfaces lose some of the grazing Fresnel boost
  vec3 Fr = f0 + (max(vec3(1.0 - m.rough), f0) - f0) * pow(1.0 - NoV, 5.0);
  col += Fr * envStudioRough(r, m.rough) * mix(vec3(1.0), shadowCol(p + n * 0.015, r), 1.0 - m.rough * 0.6) * mix(1.0, ao, 0.6);
  if (m.coat > 0.0) {
    vec3 rc = reflect(-v, n);
    float Fc = (0.04 + 0.96 * pow(1.0 - NoV, 5.0)) * m.coat;
    col = col * (1.0 - Fc) + Fc * envStudio(rc) * shadowCol(p + n * 0.015, rc);
  }
  return col;
}
// Light that a lit translucent body gives off from inside (resin, wax, jade, frosted acrylic).
vec3 subsurface(vec3 p, vec3 n, vec3 scatterCol, float strength) {
  return scatterCol * strength * (KEY_COL * keyGobo(p) * (0.35 + 0.65 * max(dot(n, KEY_DIR), 0.0)) + SKY_COL);
}`;
const OUTPUT_FS = `#version 300 es
precision highp float;
uniform sampler2D uAccum;
uniform vec2 uPx;
uniform float uExposure, uGrain, uVignette, uSeed, uHalation;
in vec2 vUv;
out vec4 o;
float ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); }
float hash(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
// ACES filmic fit: highlights roll off instead of clipping, which is most of the "camera" look
vec3 aces(vec3 x) { return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); }
void main() {
  vec3 c = texture(uAccum, vUv).rgb;
  // halation: the brightest highlights bleed a warm glow, like light scattering in film
  vec3 g = vec3(0.0);
  float rot = 6.2831853 * ign(gl_FragCoord.xy);
  for (int k = 0; k < 12; k++) {
    float a = float(k) * 2.39996 + rot, r = 2.0 + 24.0 * sqrt(float(k) / 12.0);
    g += max(texture(uAccum, vUv + vec2(cos(a), sin(a)) * r * uPx).rgb - 2.0, 0.0);
  }
  c += g / 12.0 * vec3(0.42, 0.3, 0.22) * uHalation;
  c = aces(c * uExposure);
  vec2 q = vUv - 0.5;
  c *= 1.0 - uVignette * dot(q, q) * 2.0;
  c = pow(c, vec3(1.0 / 2.2));
  // grain lives in the midtones, never in pure black or white
  float l = dot(c, vec3(0.299, 0.587, 0.114));
  // static grain (it does not crawl), meant to be barely visible: default amplitude 0.012
  c += (hash(gl_FragCoord.xy + uSeed) - 0.5) * uGrain * (0.25 + 3.0 * l * (1.0 - l));
  o = vec4(c, 1.0);
}`;
type Vec3 = [number, number, number];
type Camera = { pos: Vec3; rt: Vec3; up: Vec3; fw: Vec3; tan: number; aspect: number; shift: [number, number]; aperture: number; focus: number };
const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const norm = (a: Vec3): Vec3 => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
const cross = (a: Vec3, b: Vec3): Vec3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
function lookAt(pos: Vec3, target: Vec3, fovYdeg: number, aspect: number, aperture = 0, shift: [number, number] = [0, 0]): Camera {
  const fw = norm(sub(target, pos));
  const rt = norm(cross(fw, [0, 1, 0]));
  const up = cross(rt, fw);
  return { pos, rt, up, fw, tan: Math.tan((fovYdeg * Math.PI) / 360), aspect, shift, aperture, focus: Math.hypot(...sub(target, pos)) };
}
function project(cam: Camera, p: Vec3, cssW: number, cssH: number): [number, number, number] {
  const d = sub(p, cam.pos);
  const z = dot(d, cam.fw);
  const x = dot(d, cam.rt) / (z * cam.tan * cam.aspect) + cam.shift[0];
  const y = dot(d, cam.up) / (z * cam.tan) + cam.shift[1];
  return [(x * 0.5 + 0.5) * cssW, (0.5 - y * 0.5) * cssH, z];
}
function overlayRect(cam: Camera, center: Vec3, half: Vec3, cssW: number, cssH: number) {
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  for (let i = 0; i < 8; i++) {
    const [x, y] = project(cam, [center[0] + (i & 1 ? half[0] : -half[0]), center[1] + (i & 2 ? half[1] : -half[1]), center[2] + (i & 4 ? half[2] : -half[2])], cssW, cssH);
    x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y);
  }
  return { left: x0, top: y0, width: x1 - x0, height: y1 - y0 };
}
function compileProgram(gl: WebGL2RenderingContext, vs: string, fs: string) {
  const p = gl.createProgram()!;
  for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]] as const) {
    const s = gl.createShader(type)!;
    gl.shaderSource(s, src);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || "shader");
    gl.attachShader(p, s);
  }
  gl.linkProgram(p);
  if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) || "link");
  return p;
}
type StageOptions = {
  canvas: HTMLCanvasElement;
  wrap: HTMLElement;
  /** full fragment shader: GLSL_HEADER + chunks + your scene. Writes linear HDR to `o`. */
  sceneFS: string;
  /** called once per CSS resize, before drawing: build your camera here and return it */
  camera: (cssW: number, cssH: number) => Camera;
  /** your uniforms, every rendered frame (program is in use) */
  uniforms?: (gl: WebGL2RenderingContext, loc: (name: string) => WebGLUniformLocation | null, t: number) => void;
  /** advance your simulation; return true while anything visible is still moving */
  step?: (dt: number, t: number) => boolean;
  /** samples per pixel when at rest (then drawing stops). Default 128; never below 96 */
  samples?: number;
  /** blend weight of each new frame while moving: 0.25 (default) keeps ~4 frames of history; raise for fast motion */
  motionBlend?: number;
  maxPixels?: number; // cap on drawing buffer pixels, default 2.2e6
  maxDpr?: number; // default 2
  exposure?: number; grain?: number; vignette?: number; halation?: number;
  onFail?: () => void; // WebGL 2 missing, shader error or context lost: show your CSS still
};
function createPhotoStage(o: StageOptions) {
  const { canvas, wrap } = o;
  const fail = () => { o.onFail?.(); return null; };
  const gl = canvas.getContext("webgl2", { antialias: false, alpha: false, depth: false, powerPreference: "high-performance" });
  if (!gl) return fail();
  const float = !!gl.getExtension("EXT_color_buffer_float");
  let scene: WebGLProgram, out: WebGLProgram;
  try {
    scene = compileProgram(gl, FULLSCREEN_VS, o.sceneFS);
    out = compileProgram(gl, FULLSCREEN_VS, OUTPUT_FS);
  } catch (e) {
    console.error(e);
    return fail();
  }
  const SAMPLES = o.samples ?? 128;
  const MOTION = o.motionBlend ?? 0.25;
  let frameNo = 0;
  const maxPixels = o.maxPixels ?? 2.2e6;
  let scale = Math.min(window.devicePixelRatio || 1, o.maxDpr ?? 2);
  const vao = gl.createVertexArray();
  const fbo = gl.createFramebuffer();
  const tex = gl.createTexture();
  const locS = new Map<string, WebGLUniformLocation | null>();
  const loc = (n: string) => { if (!locS.has(n)) locS.set(n, gl.getUniformLocation(scene, n)); return locS.get(n)!; };
  const locO = (n: string) => gl.getUniformLocation(out, n);
  let W = 0, H = 0, cssW = 1, cssH = 1, n = 0, cam: Camera | null = null;

  const resize = () => {
    cssW = Math.max(1, wrap.clientWidth); cssH = Math.max(1, wrap.clientHeight);
    let k = scale;
    if (cssW * cssH * k * k > maxPixels) k = Math.sqrt(maxPixels / (cssW * cssH));
    cam = o.camera(cssW, cssH);
    n = 0;
    const w = Math.max(2, Math.round(cssW * k)), h = Math.max(2, Math.round(cssH * k));
    if (w === W && h === H) return;
    W = w; H = h; canvas.width = W; canvas.height = H;
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.texImage2D(gl.TEXTURE_2D, 0, float ? gl.RGBA16F : gl.RGBA8, W, H, 0, gl.RGBA, float ? gl.HALF_FLOAT : gl.UNSIGNED_BYTE, null);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
  };

  const draw = (t: number, moving: boolean) => {
    const c = cam!;
    gl.bindVertexArray(vao);
    gl.viewport(0, 0, W, H);
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
    gl.useProgram(scene);
    gl.uniform2f(loc("uRes"), W, H);
    gl.uniform1f(loc("uFrame"), frameNo++); // never repeats, so moving frames keep exploring new samples
    gl.uniform1f(loc("uTime"), t);
    gl.uniform3fv(loc("uCamPos"), c.pos); gl.uniform3fv(loc("uCamRt"), c.rt); gl.uniform3fv(loc("uCamUp"), c.up); gl.uniform3fv(loc("uCamFw"), c.fw);
    gl.uniform1f(loc("uTan"), c.tan); gl.uniform1f(loc("uAspect"), c.aspect); gl.uniform1f(loc("uAperture"), c.aperture); gl.uniform1f(loc("uFocus"), c.focus);
    gl.uniform2fv(loc("uShift"), c.shift);
    o.uniforms?.(gl, loc, t);
    // blend the new jittered frame into the running average: alpha 1/(n+1) is an exact mean;
    // while moving keep a SHORT history instead of restarting: alpha 0.25 is about 4 frames (a touch of motion
    // blur, no speckle). When it stops, carry on averaging from there: n continues at 1/MOTION samples.
    const alpha = moving ? MOTION : 1 / (n + 1);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.CONSTANT_ALPHA, gl.ONE_MINUS_CONSTANT_ALPHA);
    gl.blendColor(0, 0, 0, n === 0 ? 1 : alpha);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    gl.disable(gl.BLEND);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.useProgram(out);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, tex);
    gl.uniform1i(locO("uAccum"), 0);
    gl.uniform2f(locO("uPx"), 1 / W, 1 / H);
    gl.uniform1f(locO("uExposure"), o.exposure ?? 1);
    gl.uniform1f(locO("uGrain"), o.grain ?? 0.012);
    gl.uniform1f(locO("uVignette"), o.vignette ?? 0.35);
    gl.uniform1f(locO("uHalation"), o.halation ?? 0.6);
    gl.uniform1f(locO("uSeed"), 0);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    n = moving ? Math.round(1 / MOTION) : n + 1;
  };

  resize();
  const ro = new ResizeObserver(resize);
  ro.observe(wrap);

  let raf = 0, last = performance.now(), slow = 0, onScreen = true, dead = false;
  const t0 = last;
  const tick = (now: number) => {
    raf = 0;
    const real = now - last;
    last = now;
    const dt = Math.min(real / 1000, 0.05);
    const t = (now - t0) / 1000;
    const moving = o.step ? o.step(dt, t) : false;
    if (moving || n < SAMPLES) {
      if (moving) {
        // measure, do not guess: if moving frames run long, draw fewer pixels
        slow = slow * 0.9 + (real > 28 ? 0.1 : 0);
        if (slow > 0.55 && scale > 0.45) { scale *= 0.85; slow = 0; W = 0; resize(); }
      }
      draw(t, moving);
    }
    if (onScreen && !document.hidden) raf = requestAnimationFrame(tick);
  };
  const wake = () => { if (!raf && !dead && onScreen && !document.hidden) { last = performance.now(); raf = requestAnimationFrame(tick); } };
  const io = new IntersectionObserver(([e]) => { onScreen = e.isIntersecting; wake(); });
  io.observe(wrap);
  document.addEventListener("visibilitychange", wake);
  const lost = (e: Event) => { e.preventDefault(); dead = true; o.onFail?.(); };
  canvas.addEventListener("webglcontextlost", lost);
  wake();

  return {
    /** something changed that is not animated (theme, text, reduced-motion snap): restart accumulation */
    invalidate() { n = 0; wake(); },
    /** call after step() starts moving from rest (input), so the loop is running */
    wake,
    camera: () => cam!,
    size: () => [cssW, cssH] as const,
    dispose() {
      dead = true;
      cancelAnimationFrame(raf);
      ro.disconnect(); io.disconnect();
      document.removeEventListener("visibilitychange", wake);
      canvas.removeEventListener("webglcontextlost", lost);
      gl.getExtension("WEBGL_lose_context")?.loseContext();
    },
  };
}
function springStep(s: { x: number; v: number }, target: number, dt: number, k = 900, damping = 38) {
  const sub = Math.max(1, Math.ceil(dt / 0.004));
  for (let i = 0; i < sub; i++) {
    const h = dt / sub;
    s.v += (k * (target - s.x) - damping * s.v) * h;
    s.x += s.v * h;
  }
  if (Math.abs(s.v) < 0.02 && Math.abs(target - s.x) < 0.002) { s.x = target; s.v = 0; return false; }
  return true;
}
function stillObject(face: string, opts: { radius?: number; depth?: number } = {}) {
  const r = opts.radius ?? 14, d = opts.depth ?? 10;
  return {
    background: face,
    borderRadius: r,
    boxShadow: [
      "inset 0 1px 0 rgba(255,255,255,0.35)",
      "inset 0 -2px 3px rgba(0,0,0,0.22)",
      `0 ${d * 0.4}px ${d * 0.3}px rgba(0,0,0,0.28)`,
      `${d * 1.6}px ${d * 2.2}px ${d * 3}px rgba(20,12,4,0.30)`,
    ].join(", "),
  };
}
const STILL_GRAIN =
  "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0.9 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\")";
function stillWindowLight(warm = "255,236,205") {
  return {
    background: `linear-gradient(115deg, transparent 18%, rgba(${warm},0.55) 30%, rgba(${warm},0.55) 47%, transparent 48.5%, transparent 50%, rgba(${warm},0.5) 51.5%, rgba(${warm},0.45) 70%, transparent 82%)`,
    filter: "blur(6px)",
    mixBlendMode: "soft-light" as const,
  };
}
/* ---- end of kit ---- */

// The scene: three blocks of thick low-iron glass on a honed marble floor in front of a cobalt limewashed wall. Centimetres.
const SCENE = `
const float SCENE_TOP = 19.0;
const float AO_RADIUS = 2.4;
uniform float uYear;
uniform float uPoolX;
uniform sampler2D uLegend;
const float BW = ${BW.toFixed(2)}, BD = ${BD.toFixed(2)}, BR = 0.3, WZ = -34.0, PITCH = ${PITCH.toFixed(2)};
const float HS[3] = float[3](${plans.map((p) => p.height.toFixed(1)).join(", ")});
const vec3 GLASS_ABS = vec3(0.022, 0.005, 0.014);
vec3 bc(int i) { return vec3((float(i) - 1.0) * PITCH, HS[i] * 0.5, 0.0); }
vec3 bh(int i) { return vec3(BW, HS[i] * 0.5, BD); }
vec2 scene(vec3 p) {
  vec2 r = p.y < p.z - WZ ? vec2(p.y, 0.0) : vec2(p.z - WZ, 1.0);
  for (int i = 0; i < 3; i++) {
    float d = sdRoundBox(p - bc(i), bh(i), BR);
    if (d < r.x) r = vec2(d, float(i + 2));
  }
  return r;
}
float map(vec3 p) { return scene(p).x; }
// glass never blocks the sun: lightFilter tints it, and bends it into bright bands near the edges
float mapShadow(vec3 p) { return 50.0; }
float edgeDist(vec3 e) { return e.x + e.y + e.z - max(e.x, max(e.y, e.z)) - min(e.x, min(e.y, e.z)); }
vec3 lightFilter(vec3 p, vec3 l) {
  vec3 T = vec3(1.0);
  for (int i = 0; i < 3; i++) {
    vec3 b = bh(i) - 0.03, q = p - bc(i);
    vec2 tb = boxI(q, l, b);
    if (tb.y <= max(tb.x, 0.0)) continue;
    float t0 = max(tb.x, 0.0), len = tb.y - t0;
    float rim = min(edgeDist(b - abs(q + l * t0)), edgeDist(b - abs(q + l * tb.y)));
    float band = exp(-pow((rim - 0.75) / 0.32, 2.0));
    T *= beer(GLASS_ABS, len) * 0.9 * (0.12 + 0.88 * smoothstep(0.05, 0.4, rim)) * (0.95 + 1.5 * band);
  }
  return T;
}
`;
const SCENE_MAIN = `
float legendAt(int i, vec2 uv) {
  if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return 0.0;
  vec2 a = vec2((float(i) + uv.x) / 3.0, uv.y * 0.5);
  return mix(texture(uLegend, a).r, texture(uLegend, a + vec2(0.0, 0.5)).r, uYear);
}
// the sandblasted lettering on a front face (value, slope across, slope down)
vec3 legend(int i, vec3 q, vec3 n) {
  float front = smoothstep(0.85, 0.98, n.z);
  if (front <= 0.0) return vec3(0.0);
  float s = 2.0 * (BW - 0.45);
  vec2 uv = vec2(q.x / s + 0.5, (HS[i] * 0.5 - 1.0 - q.y) / s);
  float e = 1.0 / 512.0;
  return front * vec3(legendAt(i, uv), legendAt(i, uv + vec2(e, 0.0)) - legendAt(i, uv - vec2(e, 0.0)), legendAt(i, uv + vec2(0.0, e)) - legendAt(i, uv - vec2(0.0, e)));
}
// floor and wall are planes, so their normals are exact; occlusion comes from the blocks and the corner
float aoAt(vec3 p) {
  float ao = 0.55 + 0.45 * smoothstep(0.0, 9.0, min(p.y, p.z - WZ));
  for (int i = 0; i < 3; i++) {
    float k = 1.0 - smoothstep(0.0, 3.0, max(sdRoundBox(p - bc(i), bh(i), BR), 0.0));
    ao *= 1.0 - 0.5 * k * k;
  }
  return ao;
}
vec3 shadeOpaque(int id, vec3 p, vec3 v) {
  float ao = aoAt(p);
  if (id == 0) {
    // honed limestone: broad cloudy variation, a fine sanded tooth and a few tiny shell flecks, no polish
    vec2 u = p.xz;
    float cloud = fbm(u * 0.06 + fbm(u * 0.02) * 1.5);
    vec3 a = mix(vec3(0.58, 0.52, 0.44), vec3(0.71, 0.65, 0.56), cloud);
    a *= 0.95 + 0.07 * vnoise(u * 22.0) + 0.03 * vnoise(u * 3.0);
    float fleck = smoothstep(0.82, 0.9, vnoise(u * 2.6 + 11.0) * vnoise(u * 5.3 - 4.0) * 1.9);
    a = mix(a, vec3(0.45, 0.41, 0.36), fleck * 0.35);
    Mat m = mk(a, 0.6 + 0.1 * cloud, 0.0, 0.0, normalize(vec3((vnoise(u * 22.0) - 0.5) * 0.04, 1.0, (vnoise(u * 22.0 + 5.0) - 0.5) * 0.04)));
    return litOpaque(p, v, m, ao);
  }
  Mat m = matPlaster(p, vec3(0.0, 0.0, 1.0), vec3(0.03, 0.065, 0.33));
  // sun off the floor bounces up onto the wall around the pool of light
  vec3 bounce = vec3(1.0, 0.85, 0.66) * 0.55 * exp(-p.y * 0.045) * exp(-pow((p.x - uPoolX + 6.0) / 15.0, 2.0));
  return litOpaque(p, v, m, ao) + m.albedo * bounce;
}
vec3 traceOpaque(vec3 ro, vec3 rd);
// beyond the glass there is only the floor and the wall, so the bent ray finds them in closed form
vec3 traceBehind(vec3 ro, vec3 rd) {
  // a neighbouring block on the way: you see through it (tinted, with its own glint) to whatever lies beyond
  vec3 thr = vec3(1.0), add = vec3(0.0);
  for (int j = 0; j < 3; j++) {
    vec2 tb = boxI(ro - bc(j), rd, bh(j));
    if (tb.x > 0.05 && tb.y > tb.x) {
      vec3 nj = boxFaceNormal(ro + rd * tb.x - bc(j), bh(j));
      float Fj = fresnelD(max(dot(-rd, nj), 0.0), 1.5);
      add += thr * Fj * envStudio(reflect(rd, nj));
      thr *= (1.0 - Fj) * beer(GLASS_ABS, tb.y - tb.x) * 0.92;
    }
  }
  return add + thr * traceOpaque(ro, rd);
}
vec3 traceOpaque(vec3 ro, vec3 rd) {
  float t = 1e9; int id = -1;
  if (rd.y < -1e-4) { t = max(-ro.y / rd.y, 0.0); id = 0; }
  if (rd.z < -1e-4) { float tw = (WZ - ro.z) / rd.z; if (tw < t) { t = tw; id = 1; } }
  if (id < 0) return envStudio(rd);
  return shadeOpaque(id, ro + rd * t, -rd);
}
vec3 shadeGlass(int i, vec3 p, vec3 v, vec3 n) {
  vec3 c = bc(i), q = p - c, b = bh(i);
  vec3 lg = legend(i, q, n);
  vec3 nb = normalize(n + vec3(lg.y, -lg.z, 0.0) * 0.5);
  // polished faces, sandblasted lettering: the blast is just a far rougher patch of the same glass
  float frost = mix(0.025, 0.55, lg.x);
  // clear body: no random normals at all; only the blasted letters sample a rough lobe
  vec3 h = lg.x > 0.02 ? ggxSample(nb, frost, rand2(5.0)) : nb;
  float F = fresnelD(max(dot(v, nb), 0.0), 1.5);
  vec3 r = reflect(-v, nb);
  vec3 col = F * traceBehind(p + n * 0.03, r);
  // blasted letters scatter most light back: little of the scene behind shows through them, so they read on any background
  vec3 dir = refract(-v, h, 1.0 / 1.5), pos = q, thr = vec3(1.0 - F) * (1.0 - 0.7 * lg.x);
  for (int k = 0; k < 4; k++) {
    float len = max(boxI(pos, dir, b).y, 0.0);
    thr *= beer(GLASS_ABS, len);
    pos += dir * len;
    vec3 ne = boxFaceNormal(pos, b);
    // the base sits on the stone in optical contact: no mirror there, you look straight onto the floor
    if (ne.y < -0.5) { col += thr * 0.92 * shadeOpaque(0, vec3(c.x + pos.x, 0.0, c.z + pos.z), -dir); break; }
    vec3 od = refract(dir, -ne, 1.5);
    if (dot(od, od) > 1e-4) {
      thr *= 1.0 - fresnelD(dot(od, ne), 1.5);
      col += thr * traceBehind(c + pos + od * 0.03, od);
      break;
    }
    dir = reflect(dir, ne);
    pos -= ne * 0.003;
  }
  // light that came in through the back and top: the frosted letters catch it and glow, the body barely does
  vec3 inner = KEY_COL * keyGobo(p) * beer(GLASS_ABS, 2.0 * BD);
  col += subsurface(p, n, vec3(0.75, 0.88, 0.84), 0.015);
  col += lg.x * (inner * 0.4 + SKY_COL * 2.2 + FILL_COL * 3.0 + vec3(0.12)) * vec3(0.9, 0.95, 0.93);
  return col;
}
void main() {
  vec3 ro, rd;
  cameraRay(ro, rd);
  float t = 0.0;
  vec2 h = vec2(1e9, 0.0);
  bool hit = false;
  for (int s = 0; s < 200; s++) {
    h = scene(ro + rd * t);
    if (h.x < 0.0002 * t) { hit = true; break; }
    t += h.x;
    if (t > 1200.0) break;
  }
  vec3 p = ro + rd * t, col;
  int id = int(h.y + 0.5);
  if (!hit) col = envStudio(rd);
  else if (id >= 2) col = shadeGlass(id - 2, p, -rd, calcNormal(p, 0.002));
  else col = shadeOpaque(id, p, -rd);
  o = vec4(clampSample(col, 5.0), 1.0);
}
`;

const SCENE_FS = [GLSL_HEADER, GLSL_COMMON, GLSL_SAMPLING, GLSL_CAMERA, GLSL_RIG_SETTINGS, GLSL_LIGHT_RIG, GLSL_MATERIALS, GLSL_SDF, SCENE, GLSL_SHADOW_AO, GLSL_LIGHTING, SCENE_MAIN].join("\n");

/** Lettering blasted into the front of each block: white on black, one 512px tile per plan, monthly row then yearly row. */
function legendArt() {
  const cv = document.createElement("canvas");
  cv.width = 1536;
  cv.height = 1024;
  const ctx = cv.getContext("2d")!;
  ctx.fillStyle = "#000";
  ctx.fillRect(0, 0, cv.width, cv.height);
  ctx.fillStyle = "#fff";
  ctx.textBaseline = "alphabetic";
  ctx.filter = "blur(1.2px)";
  plans.forEach((p, i) => {
    for (let row = 0; row < 2; row++) {
      const x = i * 512 + 40, y = row * 512;
      ctx.textAlign = "left";
      ctx.font = `600 40px ${GROTESK}`;
      ctx.letterSpacing = "8px";
      ctx.fillText(p.name.toUpperCase(), x, y + 70);
      ctx.letterSpacing = "-10px";
      ctx.font = `500 90px ${GROTESK}`;
      ctx.fillText("$", x - 4, y + 190);
      ctx.font = `600 230px ${GROTESK}`;
      ctx.fillText(String(row ? p.yearly : p.monthly), x + 58, y + 292);
      ctx.letterSpacing = "6px";
      ctx.font = `600 34px ${GROTESK}`;
      ctx.fillText(row ? "A MONTH, YEARLY" : "A MONTH", x, y + 368);
    }
  });
  return cv;
}

// the window basis the shader uses, so the pool of sun can be slid onto a block
const KN = norm(KEY);
const KT = norm([KN[2], 0, -KN[0]]);
const KB = cross(KN, KT);
const blockAt = (i: number): Vec3 => [(i - 1) * PITCH, plans[i].height * 0.5, 0];

type Sim = { pool: { x: number; v: number }; year: { x: number; v: number }; plan: number; yearly: boolean; touched: boolean };

export default function GlassBlockPricingTable() {
  const reduce = useReducedMotion();
  const wrapRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [mode, setMode] = useState<"pending" | "gl" | "still">("pending");
  const [plan, setPlan] = useState(1);
  const [yearly, setYearly] = useState(false);
  const [rects, setRects] = useState<{ left: number; top: number; width: number; height: number }[]>([]);
  const sim = useRef<Sim>({ pool: { x: 1, v: 0 }, year: { x: 0, v: 0 }, plan: 1, yearly: false, touched: false });
  const stageRef = useRef<{ wake: () => void } | null>(null);

  useEffect(() => {
    sim.current.plan = plan;
    sim.current.yearly = yearly;
    stageRef.current?.wake();
  }, [plan, yearly]);

  useEffect(() => {
    const wrap = wrapRef.current, canvas = canvasRef.current;
    if (!wrap || !canvas) return;
    const s = sim.current;
    s.pool.x = s.plan;
    s.year.x = s.yearly ? 1 : 0;
    let legendTex: WebGLTexture | null = null;
    let demoAt = 3.4, step = 0;
    const poolPoint = (k: number): Vec3 => {
      // between two blocks the light travels in a straight line along the row
      const i = Math.min(1, Math.max(0, Math.floor(k))), f = Math.min(1, Math.max(0, k - i));
      const a = blockAt(i), b = blockAt(i + 1);
      return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, 0];
    };
    const stage = createPhotoStage({
      canvas,
      wrap,
      sceneFS: SCENE_FS,
      samples: 128,
      exposure: 1.0,
      vignette: 0.4,
      halation: 0.5,
      onFail: () => setMode("still"),
      camera: (w, h) => {
        const aspect = w / h;
        const wide = w >= 768;
        const fov = 17;
        const half = wide ? 17 : 17.5;
        const dist = Math.max(wide ? 96 : 104, half / (Math.tan((fov * Math.PI) / 360) * aspect));
        const el = 0.46;
        const target: Vec3 = [0, 6.5, 1.5];
        const cam = lookAt([0, target[1] + Math.sin(el) * dist, target[2] + Math.cos(el) * dist], target, fov, aspect, dist * 0.012, wide ? [0.2, -0.16] : [0, -0.42]);
        setRects(plans.map((_, i) => overlayRect(cam, blockAt(i), [BW, plans[i].height * 0.5, BD], w, h)));
        return cam;
      },
      uniforms: (gl, loc) => {
        gl.activeTexture(gl.TEXTURE1);
        gl.bindTexture(gl.TEXTURE_2D, legendTex);
        gl.uniform1i(loc("uLegend"), 1);
        const P = poolPoint(s.pool.x);
        gl.uniform2f(loc("uWin"), dot(P, KT), dot(P, KB));
        gl.uniform1f(loc("uPoolX"), P[0]);
        gl.uniform1f(loc("uYear"), Math.min(1, Math.max(0, s.year.x)));
      },
      step: (dt, t) => {
        if (DEMO && !reduce && !s.touched && t > demoAt) {
          const script = [() => setYearly(true), () => setPlan(2), () => setPlan(0), () => setYearly(false), () => setPlan(1)];
          script[step % script.length]();
          step++;
          demoAt = t + 3.4;
        }
        const goalYear = s.yearly ? 1 : 0;
        if (reduce) {
          const moved = s.pool.x !== s.plan || s.year.x !== goalYear;
          s.pool.x = s.plan; s.year.x = goalYear; s.pool.v = s.year.v = 0;
          return moved;
        }
        const a = springStep(s.pool, s.plan, dt, POOL_SPRING, POOL_DAMPING);
        const b = springStep(s.year, goalYear, dt, 140, 24);
        return a || b;
      },
    });
    if (!stage) return;
    stageRef.current = stage;
    const gl = canvas.getContext("webgl2")!;
    legendTex = gl.createTexture();
    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, legendTex);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, legendArt());
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    stage.invalidate();
    setMode("gl");
    return () => { stageRef.current = null; stage.dispose(); };
  }, [reduce]);

  const touch = () => { sim.current.touched = true; };
  const pick = (i: number) => { touch(); setPlan(i); };
  const chosen = plans[plan];

  return (
    <section className="relative overflow-hidden bg-[#0d1a4a] text-[#f3ecdf]" style={{ fontFamily: GROTESK }} onPointerDown={touch} onKeyDown={touch}>
      <div ref={wrapRef} className="relative h-[540px] w-full sm:h-[600px]">
        <canvas ref={canvasRef} aria-hidden="true" className={`absolute inset-0 h-full w-full ${mode === "still" ? "hidden" : ""}`} />
        {mode === "still" && (
          <div aria-hidden="true" className="absolute inset-0" style={{ background: "linear-gradient(180deg, #0f1f5c 0%, #172b78 58%, #8a8073 58%, #625a50 100%)" }}>
            <div className="absolute inset-0" style={stillWindowLight()} />
            <div className="absolute inset-x-0 bottom-[14%] flex items-end justify-center gap-[6%] sm:left-[30%]">
              {plans.map((p, i) => (
                <div key={p.name} className="w-[16%] max-w-[120px]" style={{ height: `${p.height * 13}px`, ...stillObject(i === plan ? "rgba(214,238,228,0.55)" : "rgba(190,215,210,0.3)", { radius: 4, depth: 12 }) }} />
              ))}
            </div>
            <div className="absolute inset-0 opacity-30 mix-blend-overlay" style={{ backgroundImage: STILL_GRAIN }} />
          </div>
        )}
        {mode === "gl" &&
          rects.map((r, i) => (
            <button key={plans[i].name} type="button" tabIndex={-1} aria-hidden="true" onClick={() => pick(i)} className="absolute cursor-pointer rounded-sm outline-none" style={{ left: r.left, top: r.top, width: r.width, height: r.height }} />
          ))}

        <div className="pointer-events-none absolute inset-x-0 top-0 flex flex-col gap-5 p-5 sm:p-10 md:max-w-[46%]">
          <p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-[#f3ecdf]/75">Pricing</p>
          <h2 className="text-[34px] font-semibold leading-[0.95] tracking-[-0.035em] sm:text-[56px]">One price, whatever you build.</h2>
          <div className="pointer-events-auto flex flex-wrap items-center gap-3 text-[11px] font-semibold uppercase tracking-[0.2em]">
            <span className={yearly ? "text-[#f3ecdf]/60" : ""}>Monthly</span>
            <button
              type="button"
              role="switch"
              aria-checked={yearly}
              aria-label="Bill yearly"
              onClick={() => { touch(); setYearly((v) => !v); }}
              className="relative h-9 w-[62px] rounded-full border border-[#f3ecdf]/35 bg-black/25 backdrop-blur-sm transition-colors hover:bg-black/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f3ecdf]"
            >
              <span className="absolute left-1 top-1 h-[26px] w-[26px] rounded-full bg-[#f3ecdf] shadow-[0_2px_6px_rgba(0,0,0,0.4)] transition-transform duration-300 ease-out motion-reduce:transition-none" style={{ transform: yearly ? "translateX(26px)" : "none" }} />
            </button>
            <span className={yearly ? "" : "text-[#f3ecdf]/60"}>Yearly</span>
            <span className="rounded-full bg-[#f3ecdf]/15 whitespace-nowrap px-2.5 py-1 tracking-[0.12em] normal-case">Save about 20%</span>
          </div>
        </div>
        <p className="pointer-events-none absolute bottom-4 left-5 text-[11px] text-[#f3ecdf]/60 sm:left-10">Example prices in USD.</p>
      </div>

      <div className="mx-auto max-w-6xl px-5 pb-12 pt-8 sm:px-10">
        <fieldset>
          <legend className="sr-only">Choose a plan</legend>
          <div className="grid gap-4 md:grid-cols-3">
            {plans.map((p, i) => {
              const on = plan === i;
              const price = yearly ? p.yearly : p.monthly;
              return (
                <div key={p.name} className={`flex flex-col rounded-2xl p-6 transition-colors duration-300 ${on ? "bg-[#f3ecdf] text-[#0d1a4a]" : "bg-white/[0.06] text-[#f3ecdf]"}`}>
                  <label className="cursor-pointer">
                    <input type="radio" name="glass-block-plan" aria-label={`${p.name} plan`} value={p.name} checked={on} onChange={() => pick(i)} className="peer sr-only" />
                    <span className="flex items-center justify-between rounded-md text-[12px] font-semibold uppercase tracking-[0.22em] peer-focus-visible:ring-2 peer-focus-visible:ring-current peer-focus-visible:ring-offset-4 peer-focus-visible:ring-offset-transparent">
                      {p.name}
                      <span className={`grid h-5 w-5 place-items-center rounded-full border-[1.5px] ${on ? "border-[#0d1a4a]" : "border-[#f3ecdf]/50"}`}>
                        <span className={`h-2.5 w-2.5 rounded-full bg-current transition-transform ${on ? "scale-100" : "scale-0"}`} />
                      </span>
                    </span>
                    <span className="mt-4 flex items-start font-semibold leading-none tracking-[-0.05em]">
                      <span className="mt-1 text-[22px] tracking-normal">$</span>
                      <span className="text-[56px] tabular-nums">{price}</span>
                      <span className={`ml-2 mt-2 text-[12px] font-medium leading-snug tracking-normal ${on ? "text-[#0d1a4a]/70" : "text-[#f3ecdf]/70"}`}>
                        a month
                        <span className="block">{yearly ? `$${p.yearly * 12} billed yearly` : "billed monthly"}</span>
                      </span>
                    </span>
                    <span className={`mt-3 block text-[15px] leading-snug ${on ? "text-[#0d1a4a]/80" : "text-[#f3ecdf]/80"}`}>{p.pitch}</span>
                  </label>
                  <ul className={`mt-5 flex-1 space-y-2.5 border-t pt-5 text-[14px] leading-snug ${on ? "border-[#0d1a4a]/15" : "border-[#f3ecdf]/15"}`}>
                    {p.features.map((f) => (
                      <li key={f} className="flex gap-3">
                        <svg aria-hidden="true" width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-0.5 shrink-0">
                          <path d="M4 10.5l4 4 8-9" />
                        </svg>
                        {f}
                      </li>
                    ))}
                  </ul>
                  <a
                    href="#"
                    onClick={(e) => e.preventDefault()}
                    className={`mt-6 inline-flex h-11 items-center justify-center rounded-full px-5 text-[14px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${on ? "bg-[#0d1a4a] text-[#f3ecdf] hover:bg-[#162a6e] focus-visible:ring-[#0d1a4a] focus-visible:ring-offset-[#f3ecdf]" : "bg-[#f3ecdf]/10 text-[#f3ecdf] hover:bg-[#f3ecdf]/20 focus-visible:ring-[#f3ecdf] focus-visible:ring-offset-[#0d1a4a]"}`}
                  >
                    {p.cta}
                  </a>
                </div>
              );
            })}
          </div>
        </fieldset>
        <p aria-live="polite" className="sr-only">{`${chosen.name}, $${yearly ? chosen.yearly : chosen.monthly} a month, billed ${yearly ? "yearly" : "monthly"}`}</p>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/photoreal-pricing-table-with-monthly-yearly-switch-and-three-glass-blocks-of-different-heights-in-one-shaft-of-window-light.json

When to use it

How to customize it

Accessibility

Questions

Is the pricing table usable without the 3D scene?

Yes. The cards, the radio inputs, the switch and the buttons are ordinary HTML under and over the canvas. You can delete the stage div and keep a working, styled pricing table with a monthly and yearly toggle.

How does the light pick out the chosen plan?

The window is a rectangle a long way behind the blocks. For every point the shader asks whether a line from that point toward the sun passes through the opening. Sliding the opening sideways moves the whole shaft, so the code works out, from the window's own axes, the offset that centres the opening on the chosen block and springs toward it.

Why are there bright stripes in the shadow of the glass?

Sun that reaches the stone behind a block has gone through it. The shader measures how much glass each ray crossed and how close it passed to an edge. Rays near an edge are bent away, which leaves a dark outline, and the light they lose gathers in a band just inside it. That pattern is what a real glass block throws in low sun.

How are the prices put inside the glass?

A canvas is filled with white text on black, one tile per plan for monthly and one for yearly, and uploaded as a texture. Where the text is white the front face is treated as a much rougher surface, so the light passing through scatters there and the letters read as sandblasted. The switch fades between the two tiles.

Does it keep the GPU busy?

Only while something moves. The canvas is capped at about 2.2 million pixels, frames are averaged until the picture is clean, about a second and a half, and then drawing stops. It also pauses when the section scrolls out of view or the tab is hidden.

Related: Sunlit Cast Resin Cube Pricing in WebGL Where a Shaft of Window Light Picks the Plan and the Cubes Tumble over to Yearly, Price Register with a Monthly Yearly Throw Lever That Re-Rolls Mechanical Price Drums and Latching Plan Keys, Ticket Roll Dispenser Pricing with Three Paper Rolls and a Plan Ticket That Tears Off and Lands on a Ledge.

More pricing or all free UI components. Start for free to have a full site built around it.

More in Pricing

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap