Glazed Stoneware Jar Pricing in WebGL Where the Chosen Jar Rocks Forward on a Pastel Shelf and Fills with Honey

Three wheel-thrown stoneware jars stand in a row on a painted mint shelf against a pink plaster wall: a short one in speckled oatmeal glaze, a middle one in apricot and a tall one in cobalt. Each jar carries its plan name and an example monthly price painted on the belly in white slip. Afternoon sun comes in through a casement window to the left, so the glazing bars and the leaves of a sill plant lie across the wall, and each jar throws a long soft shadow to the right. The glaze has a bare clay foot with a wavy drip line, darker pools where it ran thick, iron specks from the clay and a clear coat that mirrors the window. Pick a plan and its jar rocks forward on the front of its foot, rocks once onto its back edge and settles, while a thin thread of honey pours in from above and the level rises. The honey lags as the jar moves and slops back. The whole scene is a single ray-marched fragment shader with soft shadows, ambient occlusion and a short depth of field; the plan picker under the jars is a native radio group. On phones the camera backs away so all three jars fit, and the feature list and button drop below the picture.

Code

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

/** The product and its three plans. These are example prices: change them to your own. */
const product = { name: "Larkspur", line: "Recipe costing for small kitchens" };
const plans = [
  {
    name: "Taster",
    price: 9,
    pitch: "One menu, costed to the gram.",
    features: ["1 kitchen, 60 recipes", "Supplier price lists", "Allergen labels to print"],
    radius: 0.34, // jar radius in metres
    height: 0.8,
    glaze: [0.62, 0.5, 0.3], // speckled oatmeal, linear RGB
    slip: [0.16, 0.09, 0.06], // the paint used for the lettering
  },
  {
    name: "Kitchen",
    price: 24,
    pitch: "Every dish, every margin, every week.",
    features: ["3 kitchens, unlimited recipes", "Stock counts on a phone", "Waste log and weekly margin", "Prep lists by station"],
    radius: 0.42,
    height: 1.0,
    glaze: [0.78, 0.24, 0.11], // apricot
    slip: [0.93, 0.88, 0.78],
  },
  {
    name: "Larder",
    price: 58,
    pitch: "A group of sites on one price book.",
    features: ["Up to 12 sites", "Central ordering and transfers", "Menu engineering reports", "A named onboarding cook"],
    radius: 0.5,
    height: 1.24,
    glaze: [0.025, 0.06, 0.24], // cobalt
    slip: [0.93, 0.88, 0.78],
  },
];

const GAP = 0.36; // shelf between neighbouring jars
const ROCK = 0.3; // how far forward the chosen jar rocks, in radians
const BOUNCE = 0.32; // how much speed survives when a jar lands back on its foot
const FILL_SECONDS = 1.5; // how long the honey takes to fill a jar
const DEMO = true; // plays through the plans by itself until somebody touches it
const SERIF = '"Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif';
const GROTESK = '"Inter Tight", "Inter", ui-sans-serif, system-ui, sans-serif';
const INK = "#2a1c19";
const WALL = "#ecd3cb";

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const XS = (() => {
  const total = plans.reduce((a, p) => a + p.radius * 2, 0) + GAP * (plans.length - 1);
  let x = -total / 2;
  return plans.map((p) => {
    const c = x + p.radius;
    x += p.radius * 2 + GAP;
    return c;
  });
})();
const SUN_DIR = (() => {
  const v = [-0.6, 0.66, 0.46];
  const l = Math.hypot(v[0], v[1], v[2]);
  return v.map((x) => x / l);
})();
const SHELF_FRONT = 0.92;
const WALL_Z = -0.86;

const GLSL = `#version 300 es
precision highp float;
const vec3 L = vec3(${SUN_DIR.map((v) => v.toFixed(4)).join(", ")});
const vec3 SUN = vec3(1.0, 0.9, 0.76) * 3.4;
const vec3 SKY = vec3(0.86, 0.84, 0.88) * 0.6;
const float FRONT = ${SHELF_FRONT.toFixed(2)};
const float WZ = ${WALL_Z.toFixed(2)};
uniform vec4 uJ[3];      // x, z, radius, height
uniform vec2 uCS[3];     // cos and sin of the rock angle
uniform vec3 uPiv[3];    // the edge of the foot the jar is rocking on
uniform float uOff[3];   // which edge: +radius front, -radius back
uniform float uLv[3];    // world height of the honey
uniform float uSl[3];    // slosh tilt of the honey
uniform vec3 uGl[3];     // glaze colour
uniform vec3 uSp[3];     // slip colour
uniform vec4 uStream;    // x, z, radius, bottom
uniform sampler2D uTex;
uniform vec3 uCam, uRt, uUp, uFw;
uniform float uTan, uAspect, uFocus, uAperture, uTime;
uniform vec2 uShift;
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) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float vnoise(vec2 p) {
  vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) { return 0.5 * vnoise(p) + 0.25 * vnoise(p * 2.1 + 7.3) + 0.125 * vnoise(p * 4.3 + 1.7) + 0.0625 * vnoise(p * 8.9); }
float smax(float a, float b, float k) { float h = clamp(0.5 - 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) + k * h * (1.0 - h); }

vec3 toJ(vec3 p, int i) {
  vec3 d = p - uPiv[i];
  vec2 r = uCS[i];
  return vec3(d.x, d.y * r.x + d.z * r.y, -d.y * r.y + d.z * r.x + uOff[i]);
}
vec3 dirW(vec3 d, int i) { vec2 r = uCS[i]; return vec3(d.x, d.y * r.x - d.z * r.y, d.y * r.y + d.z * r.x); }

// the turned profile: a full belly, a soft shoulder, a wide neck and a rolled lip
float prof(float y, float R, float H) {
  float t = clamp(y / H, 0.0, 1.0);
  float body = R * (0.88 + 0.12 * sin(3.14159 * clamp(t / 0.82, 0.0, 1.0) * 0.86 + 0.3));
  float r = mix(body, R * 0.8, smoothstep(0.7, 0.9, t));
  r += R * 0.045 * smoothstep(0.93, 0.995, t);
  r -= R * 0.07 * (1.0 - smoothstep(0.0, 0.06, t));
  return r;
}
float wall(float R) { return 0.03 + 0.02 * R; }
float jarD(vec3 q, float R, float H) {
  float rho = length(q.xz), pr = prof(q.y, R, H), th = wall(R);
  float outer = smax((rho - pr) * 0.85, max(-q.y, q.y - H), 0.02);
  float inner = smax((rho - (pr - th)) * 0.85, th * 1.8 - q.y, 0.03);
  return smax(outer, -inner, 0.01);
}
float cavD(vec3 q, float R, float H) {
  float rho = length(q.xz), pr = prof(q.y, R, H), th = wall(R) + 0.004;
  return smax((rho - (pr - th)) * 0.85, th * 1.8 - q.y, 0.03);
}

// id: 0..2 jars, 3..5 honey, 6 the pouring thread
vec2 map(vec3 p, bool honey) {
  vec2 res = vec2(1e9, -1.0);
  for (int i = 0; i < 3; i++) {
    vec4 J = uJ[i];
    vec3 q = toJ(p, i);
    float bound = length(max(vec2(length(q.xz) - J.z * 1.01, abs(q.y - J.w * 0.5) - J.w * 0.51), 0.0)) * 0.85;
    if (bound > 0.3) { if (bound < res.x) res = vec2(bound, -1.0); continue; }
    float d = jarD(q, J.z, J.w);
    if (d < res.x) res = vec2(d, float(i));
    if (honey && uLv[i] > -1.0) {
      vec3 n = vec3(0.0, cos(uSl[i]), sin(uSl[i]));
      float h = max(cavD(q, J.z, J.w), dot(p - vec3(J.x, uLv[i], J.y), n));
      if (h < res.x) res = vec2(h, float(3 + i));
    }
  }
  if (honey && uStream.z > 0.0) {
    float wob = 0.012 * sin(p.y * 5.0 - uTime * 3.0);
    float rr = uStream.z * (1.0 + 0.18 * sin(p.y * 11.0 - uTime * 14.0)) * (0.7 + 0.3 * smoothstep(uStream.w, uStream.w + 0.4, p.y));
    float d = max(length(p.xz - uStream.xy - vec2(wob, 0.0)) - rr, uStream.w - 0.02 - p.y);
    if (d < res.x) res = vec2(d, 6.0);
  }
  return res;
}
vec3 nrm(vec3 p, bool honey) {
  vec2 e = vec2(0.0007, -0.0007);
  return normalize(e.xyy * map(p + e.xyy, honey).x + e.yyx * map(p + e.yyx, honey).x + e.yxy * map(p + e.yxy, honey).x + e.xxx * map(p + e.xxx, honey).x);
}
float shadow(vec3 p) {
  float s = 1.0, t = 0.02;
  for (int k = 0; k < 30; k++) {
    float h = map(p + L * t, true).x;
    s = min(s, 9.0 * h / t);
    t += clamp(h, 0.015, 0.25);
    if (s < 0.01 || t > 4.0) break;
  }
  return clamp(s, 0.0, 1.0);
}
float ao(vec3 p, vec3 n) {
  float a = 0.0, w = 1.0;
  for (int k = 1; k <= 5; k++) {
    float h = 0.016 * float(k * k);
    a += w * (h - map(p + n * h, true).x);
    w *= 0.62;
  }
  return clamp(1.0 - 2.6 * a, 0.0, 1.0);
}

// the sun comes through a tall casement to the left of the shelf: two leaves, glazing bars, and a plant on the sill
float casement(vec3 p) {
  vec3 a = normalize(cross(L, vec3(0.0, 1.0, 0.0))), b = cross(a, L);
  vec2 w = vec2(dot(p, a), dot(p, b)) - vec2(-0.2, -0.1);
  float pen = 0.02 + 0.03 * clamp(dot(p, -L) + 2.0, 0.0, 4.0);
  vec2 e = vec2(2.25, 1.6) - abs(w);
  float m = smoothstep(-pen, pen, e.x) * smoothstep(-pen, pen, e.y);
  vec2 g = abs(fract(w / vec2(1.15, 0.78) + 0.5) - 0.5) * vec2(1.15, 0.78);
  m *= smoothstep(0.02, 0.02 + pen, g.x) * smoothstep(0.014, 0.014 + pen, g.y);
  // leaves of the sill plant breaking up the light at one corner
  float leaf = smoothstep(0.6, 0.66, fbm(w * 3.4 + vec2(sin(uTime * 0.5) * 0.08, 0.0))) * smoothstep(0.0, 1.2, -w.y - 0.3 + w.x * 0.4);
  return m * (1.0 - 0.7 * leaf);
}

vec3 env(vec3 r) {
  vec3 c = mix(vec3(0.62, 0.5, 0.48), vec3(0.95, 0.84, 0.82), smoothstep(-0.3, 0.6, r.y)) * 0.7;
  c = mix(c, vec3(0.6, 0.7, 0.66) * 0.6, smoothstep(0.05, -0.2, r.y));
  // the window: bright panes with a cross bar, on the left
  vec3 wd = normalize(vec3(-0.75, 0.42, 0.52));
  float d = dot(r, wd);
  if (d > 0.0) {
    vec3 a = normalize(cross(wd, vec3(0.0, 1.0, 0.0))), b = cross(a, wd);
    vec2 q = vec2(dot(r, a), dot(r, b)) / d;
    float pane = smoothstep(0.04, 0.0, max(abs(q.x) - 0.38, abs(q.y) - 0.6));
    pane *= smoothstep(0.0, 0.02, abs(q.x)) * smoothstep(0.0, 0.02, abs(q.y - 0.1));
    c += vec3(1.0, 0.97, 0.92) * 9.0 * pane;
  }
  // a soft second window behind the camera
  c += vec3(0.9, 0.94, 1.0) * 1.4 * smoothstep(0.7, 0.95, dot(r, normalize(vec3(0.3, 0.3, 1.0))));
  return c;
}

vec3 shelfAlb(vec3 p) {
  // painted pine: a pale mint coat with the grain telegraphing through
  float g = fbm(vec2(p.x * 1.2, p.z * 22.0 + 4.0 * fbm(p.xz * 0.8)));
  vec3 a = vec3(0.4, 0.62, 0.5) * (0.93 + 0.1 * g);
  return a * (0.97 + 0.05 * vnoise(p.xz * 180.0));
}
vec3 wallAlb(vec3 p) {
  float f = fbm(p.xy * 1.4 + 3.0);
  vec3 a = mix(vec3(0.78, 0.47, 0.42), vec3(0.86, 0.58, 0.52), f);
  return a * (0.97 + 0.05 * vnoise(p.xy * 60.0));
}

vec3 shadeJar(int i, vec3 p, vec3 rd, vec3 n, float jit) {
  vec4 J = uJ[i];
  vec3 q = toJ(p, i);
  float R = J.z, H = J.w;
  float t = q.y / H, ang = atan(q.x, q.z);
  float rho = length(q.xz);
  bool inside = rho < prof(q.y, R, H) - wall(R) * 0.5 && q.y < H * 0.985;
  vec2 s = vec2(ang * R, q.y);
  vec3 glaze = uGl[i];
  // glaze pools darker where it runs thick: just above the drip line and around the shoulder
  float drip = H * 0.075 + 0.014 * sin(s.x * 26.0 + float(i)) + 0.02 * (vnoise(vec2(s.x * 9.0, 1.0)) - 0.5);
  float pool = smoothstep(0.05, 0.0, abs(q.y - drip - 0.02)) * 0.4 + smoothstep(0.08, 0.0, abs(t - 0.8)) * 0.2;
  vec3 alb = glaze * (1.0 - pool * 0.55) * (0.92 + 0.12 * fbm(s * 7.0 + float(i) * 3.0));
  // iron speckle from the clay body
  float sp = smoothstep(0.9, 0.93, vnoise(s * 140.0 + float(i) * 11.0)) * 0.8 + smoothstep(0.94, 0.96, vnoise(s * 60.0 + 5.0)) * 0.6;
  alb = mix(alb, vec3(0.12, 0.07, 0.04), sp * (i == 0 ? 0.75 : 0.35));
  float gloss = 1.0;
  float raw = 1.0 - smoothstep(-0.004, 0.004, q.y - drip);
  if (raw > 0.0) {
    vec3 clay = vec3(0.74, 0.6, 0.47) * (0.85 + 0.25 * vnoise(s * 60.0));
    alb = mix(alb, clay, raw);
    gloss = 1.0 - raw * 0.92;
  }
  // the plan is painted in white slip on the front of the belly
  if (!inside) {
    float u = ang / 2.1 + 0.5;
    float v = (t - 0.3) / 0.42;
    if (u > 0.0 && u < 1.0 && v > 0.0 && v < 1.0) {
      float m = texture(uTex, vec2(u, (float(i) + v) / 3.0)).r;
      alb = mix(alb, uSp[i], m * 0.96);
      gloss *= 1.0 - 0.3 * m;
    }
  } else {
    alb *= 0.8;
  }
  // a slow ripple from the throwing lines catches the reflections
  vec3 nb = normalize(n + dirW(vec3(0.0, 1.0, 0.0), i) * (vnoise(vec2(s.y * 26.0, ang * 0.5)) - 0.5) * 0.05);
  float sh = shadow(p + n * 0.01) * casement(p);
  float oc = ao(p, n);
  float ndl = max(dot(nb, L), 0.0);
  vec3 col = alb * (SUN * ndl * sh + SKY * oc * (0.55 + 0.45 * nb.y) + vec3(0.6, 0.78, 0.7) * 0.18 * oc * max(-nb.y, 0.0) + vec3(0.95, 0.75, 0.7) * 0.14 * oc * max(-nb.z, 0.0));
  // glaze: a clear coat over the colour
  float F = (0.04 + 0.96 * pow(1.0 - clamp(dot(-rd, nb), 0.0, 1.0), 5.0)) * gloss;
  vec3 Rf = reflect(rd, nb);
  vec3 refl = env(Rf) * mix(0.35, 1.0, oc);
  refl += SUN * sh * (24.0 * pow(max(dot(Rf, L), 0.0), 600.0) + 0.6 * pow(max(dot(Rf, L), 0.0), 40.0));
  col = mix(col, refl, F * (inside ? 0.5 : 1.0));
  col += SUN * sh * gloss * 0.04 * pow(max(dot(Rf, L), 0.0), 8.0);
  return col;
}

vec3 shadeHoney(vec3 p, vec3 rd, vec3 n, float jit) {
  float sh = shadow(p + n * 0.01) * casement(p);
  float oc = 0.45 + 0.55 * ao(p, n);
  vec3 deep = vec3(0.5, 0.2, 0.025), thin = vec3(1.0, 0.62, 0.14);
  // light that enters the honey comes back out amber; the lit edge glows
  float tr = sh * (0.4 + 0.6 * max(dot(n, L), 0.0));
  vec3 col = deep * SKY * oc * 1.3 + thin * thin * SUN * tr * 0.45 + deep * SUN * sh * 0.25 + thin * deep * 0.5;
  float F = 0.04 + 0.96 * pow(1.0 - clamp(dot(-rd, n), 0.0, 1.0), 5.0);
  vec3 R = reflect(rd, n);
  vec3 refl = env(R) * mix(0.3, 1.0, oc) + SUN * sh * 30.0 * pow(max(dot(R, L), 0.0), 900.0);
  return mix(col, refl, F);
}

vec3 shadeShelf(vec3 p, vec3 rd, vec3 n) {
  vec3 alb = n.z > 0.5 ? shelfAlb(p.zyx * vec3(1.0, 1.0, 1.0)) * 0.95 : shelfAlb(p);
  float sh = shadow(p + n * 0.003) * casement(p);
  float oc = ao(p, n);
  // the honey jars throw a little colour onto the paint beside them
  vec3 bleed = vec3(0.0);
  for (int i = 0; i < 3; i++) {
    vec3 d = vec3(uJ[i].x, uJ[i].w * 0.35, uJ[i].y) - p;
    bleed += uGl[i] * uGl[i] * uJ[i].z * 0.18 / (dot(d, d) + 0.08);
  }
  vec3 col = alb * (SUN * max(dot(n, L), 0.0) * sh + SKY * oc + bleed * SUN * 0.06 * oc);
  float F = 0.03 + 0.5 * pow(1.0 - clamp(dot(-rd, n), 0.0, 1.0), 5.0);
  col += SUN * sh * 0.05 * pow(max(dot(reflect(rd, n), L), 0.0), 30.0);
  // soft satin reflection of whatever stands on the shelf
  if (n.y > 0.5) {
    vec3 R = reflect(rd, n);
    float t = 0.02;
    for (int k = 0; k < 18; k++) {
      vec2 h = map(p + R * t, false);
      if (h.x < 0.004) { col = mix(col, uGl[int(h.y)] * SKY * 0.9, 0.22 * exp(-t * 2.5)); break; }
      t += h.x;
      if (t > 1.2) break;
    }
  }
  return col * (1.0 - 0.0 * F);
}

vec3 shadeWall(vec3 p) {
  vec3 n = vec3(0.0, 0.0, 1.0);
  vec3 alb = wallAlb(p);
  float sh = shadow(p + n * 0.003) * casement(p);
  // under the shelf the board blocks the sun
  if (p.y < 0.0) {
    float k = -p.y / L.y;
    float z = p.z + L.z * k;
    sh *= smoothstep(FRONT + 0.02, FRONT - 0.06, z) < 0.5 ? 1.0 : 0.0;
  }
  float oc = ao(p, n) * (0.55 + 0.45 * smoothstep(0.0, 0.5, abs(p.y)));
  return alb * (SUN * max(dot(n, L), 0.0) * sh + SKY * oc * 0.95 + vec3(0.6, 0.78, 0.7) * 0.1 * smoothstep(0.6, 0.0, p.y) * step(0.0, p.y));
}

vec3 tonemap(vec3 x) { x *= 0.55; return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); }

void main() {
  vec2 ndc = vUv * 2.0 - 1.0 - uShift;
  vec3 rd = normalize(uFw + uRt * (ndc.x * uTan * uAspect) + uUp * (ndc.y * uTan));
  float jit = ign(gl_FragCoord.xy);
  // the set: shelf top, shelf front edge, then the wall
  float tp = 1e9; int pid = -1; vec3 pn = vec3(0.0);
  if (rd.y < 0.0) { float t = -uCam.y / rd.y; vec3 h = uCam + rd * t; if (h.z < FRONT && h.z > WZ) { tp = t; pid = 0; pn = vec3(0.0, 1.0, 0.0); } }
  if (rd.z < 0.0) {
    float t = (FRONT - uCam.z) / rd.z; vec3 h = uCam + rd * t;
    if (t < tp && h.y < 0.0 && h.y > -0.11) { tp = t; pid = 1; pn = vec3(0.0, 0.0, 1.0); }
    t = (WZ - uCam.z) / rd.z;
    if (t < tp) { tp = t; pid = 2; pn = vec3(0.0, 0.0, 1.0); }
  }
  // the jars
  float t = 0.0; float id = -1.0;
  float tmax = min(tp, 30.0);
  t = max(0.0, (uCam.z - 1.6) / max(-rd.z, 0.05));
  for (int k = 0; k < 110; k++) {
    vec2 h = map(uCam + rd * t, true);
    if (h.x < 0.0006 * t) { id = h.y; break; }
    t += h.x;
    if (t > tmax) break;
  }
  vec3 col;
  float depth;
  if (id >= 0.0 && t < tmax) {
    vec3 p = uCam + rd * t;
    vec3 n = nrm(p, true);
    int k = int(id + 0.5);
    col = k < 3 ? shadeJar(k, p, rd, n, jit) : shadeHoney(p, rd, n, jit);
    depth = t;
  } else {
    vec3 p = uCam + rd * tp;
    col = pid == 2 ? shadeWall(p) : shadeShelf(p, rd, pn);
    if (pid == 1) col *= 0.82;
    depth = tp;
  }
  // light in the air near the window
  col += vec3(1.0, 0.9, 0.78) * 0.035 * smoothstep(0.4, -1.0, dot(rd, vec3(1.0, 0.0, 0.0)));
  float z = depth * dot(rd, uFw);
  float coc = clamp(uAperture * (1.0 - uFocus / max(z, 0.05)), -1.0, 1.0);
  o = vec4(pow(tonemap(col), vec3(1.0 / 2.2)), 0.5 + 0.5 * coc);
}`;

const 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 FS_LENS = `#version 300 es
precision highp float;
uniform sampler2D uScene;
uniform vec2 uPx;
uniform float uMaxR, uTime;
in vec2 vUv;
out vec4 o;
float ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); }
void main() {
  vec4 c0 = texture(uScene, vUv);
  float s0 = c0.a * 2.0 - 1.0, coc0 = abs(s0);
  vec3 acc = c0.rgb; float wsum = 1.0;
  float rot = 6.2832 * ign(gl_FragCoord.xy);
  for (int k = 1; k < 20; k++) {
    float r = sqrt(float(k) / 20.0) * uMaxR;
    float a = float(k) * 2.39996 + rot;
    vec4 s = texture(uScene, vUv + vec2(cos(a), sin(a)) * r * uPx);
    float sc = s.a * 2.0 - 1.0, cs = abs(sc);
    if (sc > s0) cs = min(cs, coc0 * 1.6 + 0.02);
    float w = smoothstep(r - 1.0, r + 1.0, cs * uMaxR);
    acc += s.rgb * w; wsum += w;
  }
  vec3 col = acc / wsum;
  // smooth the stair-steps along the jar silhouettes where the lens has not already softened them
  const vec3 LU = vec3(0.299, 0.587, 0.114);
  vec3 cNW = texture(uScene, vUv + vec2(-1.0, -1.0) * uPx).rgb, cNE = texture(uScene, vUv + vec2(1.0, -1.0) * uPx).rgb;
  vec3 cSW = texture(uScene, vUv + vec2(-1.0, 1.0) * uPx).rgb, cSE = texture(uScene, vUv + vec2(1.0, 1.0) * uPx).rgb;
  float lNW = dot(cNW, LU), lNE = dot(cNE, LU), lSW = dot(cSW, LU), lSE = dot(cSE, LU), lM = dot(c0.rgb, LU);
  float lMin = min(lM, min(min(lNW, lNE), min(lSW, lSE))), lMax = max(lM, max(max(lNW, lNE), max(lSW, lSE)));
  vec2 dir = vec2(-((lNW + lNE) - (lSW + lSE)), (lNW + lSW) - (lNE + lSE));
  float rcp = 1.0 / (min(abs(dir.x), abs(dir.y)) + max((lNW + lNE + lSW + lSE) * 0.03125, 0.0078));
  dir = clamp(dir * rcp, -8.0, 8.0) * uPx;
  vec3 fa = 0.5 * (texture(uScene, vUv + dir * (1.0 / 3.0 - 0.5)).rgb + texture(uScene, vUv + dir * (2.0 / 3.0 - 0.5)).rgb);
  vec3 fb = fa * 0.5 + 0.25 * (texture(uScene, vUv - dir * 0.5).rgb + texture(uScene, vUv + dir * 0.5).rgb);
  float lB = dot(fb, LU);
  vec3 fx = (lB < lMin || lB > lMax) ? fa : fb;
  col = mix(fx, col, smoothstep(0.05, 0.3, coc0));
  vec2 q = vUv - 0.5;
  col *= 1.0 - 0.32 * dot(q, q);
  col += (ign(gl_FragCoord.xy + fract(uTime) * 61.0) - 0.5) * 0.018;
  o = vec4(col, 1.0);
}`;

function program(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;
}

/** The slip painting on each jar: plan name, the example price and its label. One band per plan. */
function artwork() {
  const W = 1024, H = 512;
  const cv = document.createElement("canvas");
  cv.width = W;
  cv.height = H * plans.length;
  const ctx = cv.getContext("2d")!;
  ctx.fillStyle = "#000";
  ctx.fillRect(0, 0, cv.width, cv.height);
  ctx.fillStyle = "#fff";
  ctx.textAlign = "center";
  const tracked = (s: string, y: number, font: string, gap: number) => {
    ctx.font = font;
    const w = [...s].map((c) => ctx.measureText(c).width + gap);
    let x = W / 2 - (w.reduce((a, b) => a + b, 0) - gap) / 2;
    ctx.textAlign = "left";
    [...s].forEach((c, i) => {
      ctx.fillText(c, x, y);
      x += w[i];
    });
  };
  plans.forEach((plan, i) => {
    // the texture is flipped on upload, so plan 0 is drawn in the bottom band
    const y0 = (plans.length - 1 - i) * H;
    ctx.save();
    ctx.translate(0, y0);
    tracked(plan.name.toUpperCase(), 96, `600 64px ${GROTESK}`, 14);
    ctx.fillRect(W / 2 - 150, 128, 300, 5);
    tracked(`$${plan.price}`, 354, `italic 500 230px ${SERIF}`, -6);
    tracked("A MONTH ยท EXAMPLE", 452, `600 42px ${GROTESK}`, 8);
    ctx.restore();
  });
  return cv;
}

type Sim = {
  a: number[]; om: number[]; fill: number[]; sl: number[]; vsl: number[]; pour: number; pourAt: number;
  plan: number; dirty: boolean; touched: boolean;
};

/** The same shelf drawn with plain elements, for browsers without WebGL 2. */
function Still({ plan }: { plan: number }) {
  const hex = ["#cdb37f", "#e27a4f", "#1f3f86"];
  return (
    <div aria-hidden="true" className="absolute inset-0 overflow-hidden" style={{ background: `linear-gradient(180deg, ${WALL} 0%, #f3ddd5 60%, #b6d4c4 60%, #a9cbb9 74%, #7f9f8f 74%, #d8b9b0 77%, #c9a69c 100%)` }}>
      <div className="absolute left-0 top-0 h-[60%] w-[55%]" style={{ background: "linear-gradient(115deg, rgba(255,247,232,0.75), rgba(255,247,232,0) 70%)" }} />
      {plans.map((p, i) => {
        const lit = i === plan;
        const w = p.radius * 26;
        return (
          <div key={p.name} className="absolute -translate-x-1/2" style={{ left: `${[24, 50, 77][i]}%`, bottom: "31%", width: `${w}%`, aspectRatio: `${(p.radius * 2) / p.height}` }}>
            <div className="absolute -bottom-[3%] left-[20%] h-[8%] w-[110%] rounded-[50%] bg-black/25 blur-md" />
            <div className="absolute inset-0 rounded-b-[22%] rounded-t-[38%]" style={{ background: `radial-gradient(circle at 30% 34%, rgba(255,255,255,0.55), rgba(255,255,255,0) 18%), linear-gradient(90deg, ${hex[i]} 0%, ${hex[i]} 45%, rgba(0,0,0,0.35) 100%), ${hex[i]}`, backgroundBlendMode: "screen, multiply, normal", transform: lit ? "rotate(-4deg)" : "none", transformOrigin: "50% 100%" }}>
              <div className="absolute inset-x-0 top-[34%] text-center text-[#f6efe2]" style={{ fontFamily: GROTESK }}>
                <div className="text-[8px] font-semibold uppercase tracking-[0.2em] sm:text-[11px]">{p.name}</div>
                <div className="text-[clamp(18px,4vw,48px)] italic leading-none" style={{ fontFamily: SERIF }}>${p.price}</div>
              </div>
            </div>
          </div>
        );
      })}
    </div>
  );
}

export default function GlazedJarPricing() {
  const reduce = useReducedMotion();
  const wrapRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [plan, setPlan] = useState(1);
  const [mode, setMode] = useState<"pending" | "gl" | "still">("pending");
  const [marks, setMarks] = useState<number[]>([26, 50, 75]);
  const [handled, setHandled] = useState(false);
  const sim = useRef<Sim>({ a: [0, 0, 0], om: [0, 0, 0], fill: [0, 1, 0], sl: [0, 0, 0], vsl: [0, 0, 0], pour: 0, pourAt: -1, plan: 1, dirty: true, touched: false });

  // choosing a plan: the jar rocks forward on its foot and honey pours in
  useEffect(() => {
    const s = sim.current;
    if (s.plan !== plan) {
      s.plan = plan;
      const p = plans[plan];
      const hc = p.height * 0.48, phi = Math.atan(p.radius / hc);
      const K = (9.8 * Math.hypot(p.radius, hc)) / ((4 / 3) * (p.radius * p.radius + hc * hc));
      s.om[plan] = Math.sqrt(2 * K * (Math.cos(phi - ROCK) - Math.cos(phi)) * 1.02);
      s.pourAt = 0.32;
    }
    s.dirty = true;
  }, [plan]);

  useEffect(() => {
    const wrap = wrapRef.current;
    const canvas = canvasRef.current;
    if (!wrap || !canvas) return;
    const gl = canvas.getContext("webgl2", { antialias: false, alpha: false, powerPreference: "high-performance" });
    if (!gl) {
      setMode("still");
      return;
    }
    const s = sim.current;
    let pScene: WebGLProgram, pLens: WebGLProgram;
    try {
      pScene = program(gl, VS, GLSL);
      pLens = program(gl, VS, FS_LENS);
    } catch {
      setMode("still");
      return;
    }
    setMode("gl");

    const art = gl.createTexture();
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, art);
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, artwork());
    gl.generateMipmap(gl.TEXTURE_2D);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_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.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);

    const vao = gl.createVertexArray();
    const fbo = gl.createFramebuffer();
    const tex = gl.createTexture();
    let W = 0, H = 0, scale = Math.min(window.devicePixelRatio || 1, 2), cssW = 1, cssH = 1;
    const TAN = Math.tan((10 * Math.PI) / 180);
    const cam = { pos: [0, 2, 10], rt: [1, 0, 0], up: [0, 1, 0], fw: [0, 0, -1], shift: [0, 0], focus: 8 };

    const aim = (sx: number, sy: number) => {
      const aspect = cssW / cssH;
      const wide = aspect > 1.2;
      const dist = Math.max(9.2, 1.85 / (TAN * aspect));
      const el = 0.3;
      const target = [0, 0.5, 0];
      cam.pos = [target[0] + sx, target[1] + Math.sin(el) * dist + sy, target[2] + Math.cos(el) * dist];
      let fw = [target[0] - cam.pos[0], target[1] - cam.pos[1], target[2] - cam.pos[2]];
      const fl = Math.hypot(fw[0], fw[1], fw[2]);
      fw = fw.map((v) => v / fl);
      let rt = [-fw[2], 0, fw[0]];
      const rl = Math.hypot(rt[0], rt[2]);
      rt = rt.map((v) => v / rl);
      cam.fw = fw;
      cam.rt = rt;
      cam.up = [rt[1] * fw[2] - rt[2] * fw[1], rt[2] * fw[0] - rt[0] * fw[2], rt[0] * fw[1] - rt[1] * fw[0]];
      cam.shift = wide ? [0.08, -0.36] : [0, -0.48];
      cam.focus = fl;
      return aspect;
    };
    const project = (p: number[]) => {
      const d = [p[0] - cam.pos[0], p[1] - cam.pos[1], p[2] - cam.pos[2]];
      const z = d[0] * cam.fw[0] + d[1] * cam.fw[1] + d[2] * cam.fw[2];
      const x = (d[0] * cam.rt[0] + d[1] * cam.rt[1] + d[2] * cam.rt[2]) / (z * TAN * (cssW / cssH)) + cam.shift[0];
      const y = (d[0] * cam.up[0] + d[1] * cam.up[1] + d[2] * cam.up[2]) / (z * TAN) + cam.shift[1];
      return [(x * 0.5 + 0.5) * cssW, (0.5 - y * 0.5) * cssH];
    };

    const resize = () => {
      cssW = wrap.clientWidth;
      cssH = wrap.clientHeight;
      let k = scale;
      if (cssW * cssH * k * k > 1.6e6) k = Math.sqrt(1.6e6 / (cssW * cssH));
      const w = Math.max(2, Math.round(cssW * k)), h = Math.max(2, Math.round(cssH * k));
      aim(0, 0);
      setMarks(XS.map((x) => (project([x, 0, 0])[0] / cssW) * 100));
      if (w === W && h === H) return;
      W = w;
      H = h;
      canvas.width = W;
      canvas.height = H;
      gl.activeTexture(gl.TEXTURE1);
      gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, W, H, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
      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);
      gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
      s.dirty = true;
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(wrap);

    const U = (p: WebGLProgram, n: string) => gl.getUniformLocation(p, n);
    // where the honey sits, the edge each jar rocks on and where the thread of honey lands
    const pose = (i: number) => {
      const p = plans[i], a = s.a[i], sg = a >= 0 ? 1 : -1, c = Math.cos(a), sn = Math.sin(a);
      const piv = [XS[i], 0, sg * p.radius];
      const th = 0.03 + 0.02 * p.radius;
      const yb = th * 1.8, yt = p.height * 0.9;
      const y = yb + s.fill[i] * (yt - yb);
      const level = s.fill[i] < 0.01 ? -5 : y * c + sg * p.radius * Math.abs(sn);
      const mouthZ = p.height * sn + sg * p.radius * (1 - c);
      return { piv, off: sg * p.radius, c, sn, level, mouthZ };
    };
    let pointer = [0, 0], view = [0, 0];

    const draw = (t: number) => {
      const aspect = aim(view[0], view[1]);
      gl.bindVertexArray(vao);
      gl.viewport(0, 0, W, H);
      gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
      gl.useProgram(pScene);
      const J: number[] = [], CS: number[] = [], PV: number[] = [], OF: number[] = [], LV: number[] = [], SL: number[] = [], GL: number[] = [], SP: number[] = [];
      plans.forEach((p, i) => {
        const q = pose(i);
        J.push(XS[i], 0, p.radius, p.height);
        CS.push(q.c, q.sn);
        PV.push(...q.piv);
        OF.push(q.off);
        LV.push(q.level);
        SL.push(s.sl[i]);
        GL.push(...p.glaze);
        SP.push(...p.slip);
      });
      gl.uniform4fv(U(pScene, "uJ"), J);
      gl.uniform2fv(U(pScene, "uCS"), CS);
      gl.uniform3fv(U(pScene, "uPiv"), PV);
      gl.uniform1fv(U(pScene, "uOff"), OF);
      gl.uniform1fv(U(pScene, "uLv"), LV);
      gl.uniform1fv(U(pScene, "uSl"), SL);
      gl.uniform3fv(U(pScene, "uGl"), GL);
      gl.uniform3fv(U(pScene, "uSp"), SP);
      const q = pose(s.plan);
      gl.uniform4f(U(pScene, "uStream"), XS[s.plan], q.mouthZ, 0.022 * s.pour, Math.max(q.level, 0.05));
      gl.uniform1i(U(pScene, "uTex"), 0);
      gl.uniform3fv(U(pScene, "uCam"), cam.pos);
      gl.uniform3fv(U(pScene, "uRt"), cam.rt);
      gl.uniform3fv(U(pScene, "uUp"), cam.up);
      gl.uniform3fv(U(pScene, "uFw"), cam.fw);
      gl.uniform1f(U(pScene, "uTan"), TAN);
      gl.uniform1f(U(pScene, "uAspect"), aspect);
      gl.uniform2fv(U(pScene, "uShift"), cam.shift);
      gl.uniform1f(U(pScene, "uFocus"), cam.focus);
      gl.uniform1f(U(pScene, "uAperture"), 1.6);
      gl.uniform1f(U(pScene, "uTime"), t);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
      gl.useProgram(pLens);
      gl.uniform1i(U(pLens, "uScene"), 1);
      gl.uniform2f(U(pLens, "uPx"), 1 / W, 1 / H);
      gl.uniform1f(U(pLens, "uMaxR"), H * 0.007);
      gl.uniform1f(U(pLens, "uTime"), reduce ? 0 : t);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    };

    // A jar on the edge of its foot is an upside-down pendulum: gravity pulls it back until it lands and rocks once the other way.
    const physics = (dt: number) => {
      plans.forEach((p, i) => {
        const hc = p.height * 0.48, phi = Math.atan(p.radius / hc);
        const K = (9.8 * Math.hypot(p.radius, hc)) / ((4 / 3) * (p.radius * p.radius + hc * hc));
        const a0 = s.a[i];
        const acc = a0 === 0 && s.om[i] === 0 ? 0 : -Math.sign(a0 || s.om[i]) * K * Math.sin(phi - Math.abs(a0));
        const before = s.om[i];
        s.om[i] += acc * dt;
        let a = a0 + s.om[i] * dt;
        if (a0 !== 0 && Math.sign(a) !== Math.sign(a0)) {
          // landing on the foot: most of the speed goes into the shelf
          const v = s.om[i] * BOUNCE;
          a = 0;
          s.om[i] = Math.abs(v) < 0.08 ? 0 : v;
        }
        s.a[i] = a;
        // the honey lags behind the jar and slops back
        const angAcc = (s.om[i] - before) / Math.max(dt, 1e-4);
        s.vsl[i] += (-60 * s.sl[i] - 3.2 * s.vsl[i] - angAcc * 0.012) * dt;
        s.sl[i] = clamp(s.sl[i] + s.vsl[i] * dt, -0.35, 0.35);
        const want = i === s.plan ? 1 : 0;
        if (want === 0) s.fill[i] = Math.max(0, s.fill[i] - dt * 0.9);
      });
      if (s.pourAt >= 0) {
        s.pourAt -= dt;
        if (s.pourAt < 0) s.pourAt = -1;
      }
      const filling = s.pourAt < 0 && s.fill[s.plan] < 1;
      if (filling && s.pourAt === -1) s.fill[s.plan] = Math.min(1, s.fill[s.plan] + dt / FILL_SECONDS);
      const target = s.pourAt === -1 && s.fill[s.plan] < 0.995 ? 1 : 0;
      s.pour += (target - s.pour) * Math.min(1, dt * (target ? 9 : 3.5));
      if (s.pour < 0.02 && !target) s.pour = 0;
    };
    const settle = () => {
      plans.forEach((_, i) => {
        s.a[i] = 0;
        s.om[i] = 0;
        s.sl[i] = s.vsl[i] = 0;
        s.fill[i] = i === s.plan ? 1 : 0;
      });
      s.pour = 0;
      s.pourAt = -1;
    };
    settle();

    let raf = 0, visible = true, last = performance.now(), slow = 0, frames = 0;
    const t0 = last;
    let played = 0, demoAt = 4.0, step = 0;
    const tick = (now: number) => {
      const real = now - last;
      const dt = clamp(real / 1000, 0, 0.05);
      last = now;
      const t = (now - t0) / 1000;
      if (reduce) {
        if (s.dirty) settle();
      } else {
        const sub = Math.max(1, Math.ceil(dt / 0.004));
        for (let i = 0; i < sub; i++) physics(dt / sub);
        played += dt;
        view[0] += (pointer[0] * 0.35 + 0.12 * Math.sin(t * 0.21) - view[0]) * Math.min(1, dt * 3);
        view[1] += (pointer[1] * 0.18 - view[1]) * Math.min(1, dt * 3);
        if (DEMO && !s.touched && played > demoAt) {
          const script = [2, 0, 1];
          setPlan(script[step % script.length]);
          step++;
          demoAt = played + 3.6;
        }
      }
      if (!reduce || s.dirty) {
        draw(reduce ? 0 : t);
        s.dirty = false;
        if (!reduce && ++frames > 20) {
          slow = slow * 0.92 + (real > 24 ? 1 : 0) * 0.08;
          if (slow > 0.6 && scale > 0.5) {
            scale *= 0.84;
            slow = 0;
            frames = 0;
            resize();
          }
        }
      }
      if (visible) raf = requestAnimationFrame(tick);
    };
    const io = new IntersectionObserver(([e]) => {
      const was = visible;
      visible = e.isIntersecting;
      if (visible && !was) {
        last = performance.now();
        frames = 0;
        raf = requestAnimationFrame(tick);
      }
    });
    io.observe(wrap);
    const onVis = () => {
      if (document.hidden) cancelAnimationFrame(raf);
      else if (visible) {
        last = performance.now();
        raf = requestAnimationFrame(tick);
      }
    };
    document.addEventListener("visibilitychange", onVis);
    raf = requestAnimationFrame(tick);

    // pressing a jar chooses its plan
    const over = (e: PointerEvent) => {
      const r = wrap.getBoundingClientRect();
      const x = e.clientX - r.left, y = e.clientY - r.top;
      for (let i = 0; i < plans.length; i++) {
        const p = plans[i];
        const a = project([XS[i] - p.radius, p.height, 0]), b = project([XS[i] + p.radius, 0, p.radius]);
        if (x > a[0] - 6 && x < b[0] + 6 && y > a[1] - 6 && y < b[1] + 6) return i;
      }
      return -1;
    };
    const touch = () => {
      s.touched = true;
      setHandled(true);
    };
    const down = (e: PointerEvent) => {
      touch();
      const i = over(e);
      if (i >= 0) setPlan(i);
    };
    const move = (e: PointerEvent) => {
      const r = wrap.getBoundingClientRect();
      pointer = [((e.clientX - r.left) / r.width - 0.5) * 2, ((e.clientY - r.top) / r.height - 0.5) * -2];
      canvas.style.cursor = over(e) >= 0 ? "pointer" : "";
    };
    const leave = () => {
      pointer = [0, 0];
    };
    const lost = (e: Event) => {
      e.preventDefault();
      setMode("still");
    };
    canvas.addEventListener("pointerdown", down);
    canvas.addEventListener("pointermove", move);
    canvas.addEventListener("pointerleave", leave);
    canvas.addEventListener("webglcontextlost", lost);
    wrap.addEventListener("keydown", touch);
    wrap.addEventListener("focusin", touch);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      canvas.removeEventListener("pointerdown", down);
      canvas.removeEventListener("pointermove", move);
      canvas.removeEventListener("pointerleave", leave);
      canvas.removeEventListener("webglcontextlost", lost);
      wrap.removeEventListener("keydown", touch);
      wrap.removeEventListener("focusin", touch);
      gl.getExtension("WEBGL_lose_context")?.loseContext();
    };
  }, [reduce]);

  const chosen = plans[plan];
  const check = (
    <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>
  );
  const cta = (
    <a
      href="#"
      className="inline-flex h-11 items-center gap-3 rounded-full bg-[#2a1c19] pl-5 pr-4 text-[14px] font-semibold text-[#fbefe9] transition hover:bg-[#432c27] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2a1c19] focus-visible:ring-offset-2 focus-visible:ring-offset-[#ecd3cb]"
    >
      Start with {chosen.name}
      <svg aria-hidden="true" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
        <path d="M4 10h12M11 5l5 5-5 5" />
      </svg>
    </a>
  );

  return (
    <section className="relative overflow-hidden" style={{ fontFamily: GROTESK, background: WALL, color: INK }}>
      <div ref={wrapRef} className="relative h-[620px] w-full sm:h-[780px]">
        <canvas ref={canvasRef} aria-hidden="true" className={`absolute inset-0 h-full w-full touch-pan-y ${mode === "still" ? "hidden" : ""}`} />
        {mode === "still" && <Still plan={plan} />}

        <div className="pointer-events-none absolute inset-x-0 top-0 flex items-center justify-between gap-4 p-5 sm:p-9">
          <p className="flex items-baseline gap-3">
            <span className="text-[22px] italic leading-none sm:text-[26px]" style={{ fontFamily: SERIF, letterSpacing: "-0.02em" }}>
              {product.name}
            </span>
            <span className="hidden text-[11px] font-semibold uppercase tracking-[0.24em] text-[#2a1c19]/75 sm:inline">{product.line}</span>
          </p>
          <span className="rounded-full border border-[#2a1c19]/30 bg-[#fbefe9]/60 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] backdrop-blur-sm">Example prices</span>
        </div>

        <div className="pointer-events-none absolute left-5 top-[70px] sm:left-9 sm:top-[104px]" aria-live={handled ? "polite" : "off"}>
          <p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-[#2a1c19]/80">
            <span className="tabular-nums">{`0${plan + 1}`}</span>
            <span className="mx-2 text-[#2a1c19]/45">/</span>
            {chosen.name}
          </p>
          <h2 className="mt-1 flex items-start pb-1 font-semibold leading-[0.84]" style={{ letterSpacing: "-0.06em" }}>
            <span className="mr-1 mt-[0.12em] text-[34px] font-medium sm:text-[60px]" style={{ letterSpacing: 0 }}>
              $
            </span>
            <span className="overflow-hidden">
              <motion.span
                key={plan}
                initial={reduce ? false : { y: "0.6em", opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                transition={{ type: "spring", stiffness: 240, damping: 22 }}
                className="block text-[88px] tabular-nums sm:text-[160px]"
              >
                {chosen.price}
              </motion.span>
            </span>
            <span className="ml-3 mt-[0.6em] text-[11px] font-semibold uppercase leading-[1.5] tracking-[0.2em] text-[#2a1c19]/80 sm:ml-4 sm:mt-[1.4em] sm:text-[12px]">
              a month
              <span className="block normal-case tracking-normal" style={{ letterSpacing: "0.01em" }}>
                example price
              </span>
            </span>
          </h2>
          <p className="mt-1 max-w-[19ch] text-[19px] italic leading-tight sm:mt-2 sm:max-w-none sm:text-[25px]" style={{ fontFamily: SERIF, letterSpacing: "-0.01em" }}>
            {chosen.pitch}
          </p>
        </div>

        <div className="absolute right-9 top-[104px] hidden w-[262px] lg:block">
          <ul className="space-y-2 border-t border-[#2a1c19]/25 pt-3.5 text-[14px] leading-snug">
            {chosen.features.map((f) => (
              <li key={f} className="flex gap-3">
                {check}
                {f}
              </li>
            ))}
          </ul>
          <div className="mt-4">{cta}</div>
        </div>

        <fieldset className="absolute inset-x-0 bottom-4 h-11 sm:bottom-6">
          <legend className="sr-only">Plan</legend>
          {plans.map((p, i) => (
            <label
              key={p.name}
              htmlFor={`glazed-jar-pricing-plan-${i}`}
              className="absolute top-0 -translate-x-1/2 cursor-pointer"
              style={{ left: `${mode === "still" ? [24, 50, 77][i] : marks[i]}%` }}
            >
              <input id={`glazed-jar-pricing-plan-${i}`} type="radio" name="glazed-jar-pricing-plan" value={p.name} checked={plan === i} onChange={() => setPlan(i)} className="peer sr-only" />
              <span className="flex h-11 items-center gap-2 whitespace-nowrap rounded-full border border-[#2a1c19]/15 bg-[#fbefe9]/90 pl-2.5 pr-3 text-[11px] font-semibold uppercase tracking-[0.08em] shadow-[0_6px_18px_rgba(60,30,20,0.18)] transition peer-checked:bg-[#2a1c19] peer-checked:text-[#fbefe9] peer-focus-visible:ring-2 peer-focus-visible:ring-[#2a1c19] peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-[#fbefe9] sm:gap-2.5 sm:pl-3.5 sm:pr-4 sm:text-[13px] sm:tracking-[0.14em]">
                <span className={`grid h-4 w-4 place-items-center rounded-full border-[1.5px] ${plan === i ? "border-[#fbefe9]" : "border-[#2a1c19]/60"}`}>
                  <span className={`h-2 w-2 rounded-full bg-[#fbefe9] transition-transform ${plan === i ? "scale-100" : "scale-0"}`} />
                </span>
                {p.name}
                <span className="hidden font-medium normal-case tracking-normal opacity-80 sm:inline">${p.price}</span>
              </span>
            </label>
          ))}
        </fieldset>
      </div>

      <div className="border-t border-[#2a1c19]/15 px-5 pb-8 pt-6 lg:hidden">
        <ul className="space-y-2.5 text-[15px] leading-snug">
          {chosen.features.map((f) => (
            <li key={f} className="flex gap-3">
              {check}
              {f}
            </li>
          ))}
        </ul>
        <div className="mt-6">{cta}</div>
        <p className="mt-4 text-[13px] text-[#2a1c19]/80">Prices shown are examples. Set your own in the plans list.</p>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/glazed-stoneware-jar-pricing-in-webgl-where-the-chosen-jar-rocks-forward-on-a-pastel-shelf-and-fills-with-honey.json

When to use it

How to customize it

Accessibility

Questions

How are the jars modelled without a 3D library?

Each jar is a profile curve spun around its axis: a belly, a shoulder, a wide neck and a rolled lip, measured as a distance from any point in space. The inside is the same curve pulled in by the wall thickness, subtracted from the outside, so the opening is real and you can look down into the honey. The shader steps along each pixel's ray using those distances until it touches a surface.

Why does the jar rock back instead of falling over?

When a jar tips onto the front of its foot, its centre of mass is still behind that edge, so gravity pulls it upright again. The code treats it as an upside-down pendulum on that edge, gives it just enough speed to reach the `ROCK` angle, and keeps a share of the speed on landing so it rocks once onto the back edge before it stops.

How does the honey stay level when the jar moves?

The honey is the inside of the jar cut by a flat plane that is set in world space, not in the jar's own frame, so it stays horizontal while the jar tips. A small spring is driven by the jar's angular acceleration and tilts that plane a little, which is the slop you see after each landing.

Where do the shadows from the window come from?

The sun has a fixed direction from the upper left. For any lit point the shader projects it along that direction onto a plane and checks it against two casement leaves with glazing bars and a noise pattern for the sill plant. A short soft-shadow march toward the sun against the jars gives the long shadows on the wall and the darker contact under each foot.

Are the prices real?

No. The demo prices are examples, and the piece says so in a badge at the top, beside the large price and on each jar. Put your own values in `plans` before using it on a live page.

What does a browser without WebGL 2 see?

The radio group, the price, the sentence, the feature list and the button are ordinary page elements and keep working. Behind them a still made from CSS gradients shows the wall, the mint shelf and three rounded jars with their names and prices.

Related: Sunlit Cast Resin Cube Pricing in WebGL Where a Shaft of Window Light Picks the Plan and the Cubes Tumble over to Yearly, Brass Balance Scale Price Builder with Feature Weights and Coins That Drop Until the Beam Levels, 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