Cymatics
A hero beside a speaker drawn in sand: the frame and screws hold their lines while the cone rings through a sweeping tone, its sand sliding to one to five still circles. A woofer, or the whole box.
View codeA hero over the floor of a pool in the sun: a net of bright lines ray-traced by native WebGL 2 from two dozen swells, and calm water under the text.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Caustics to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<section class="kodza-caustics" data-kodza-caustics>
<div class="kodza-caustics__content">
<p class="kodza-caustics__note">Pool 29 °C · Channel 14 °C</p>
<p class="kodza-caustics__eyebrow">Hotel & baths · Saint-Malo · open from April</p>
<div class="kodza-caustics__grid">
<h1 class="kodza-caustics__title">
<span class="kodza-caustics__line">Seven rooms.</span>
<span class="kodza-caustics__line">One sea.</span>
</h1>
<div class="kodza-caustics__side">
<p class="kodza-caustics__lead">Seven rooms on the ramparts, a pool on the roof, and the Channel right below. The pool is heated. The Channel isn't.</p>
<p class="kodza-caustics__actions">
<a class="kodza-caustics__button" href="#">Book a room</a>
<a class="kodza-caustics__button kodza-caustics__button--ghost" href="#">See the pool</a>
</p>
</div>
</div>
</div>
</section>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Caustics
A hero over the floor of a pool in the sun: the light the surface gathers into a net of bright
lines, and calmer water under the text. One WebGL 2 canvas, paused whenever nobody can see it.
Tuned with the --kz-caustics-* variables. */
.kodza-caustics {
--kz-caustics-bg: #babab6; /* the floor in the shade */
--kz-caustics-light: #fff; /* the light on it */
--kz-caustics-strength: 0.55; /* how much light reaches the floor, 0 to 1 */
--kz-caustics-ink: #0a0a0a; /* the text and the button */
--kz-caustics-muted: rgba(10, 10, 10, 0.7);
--kz-caustics-on-ink: #fff; /* the button's label */
--kz-caustics-scale: 480px; /* the size of the waves */
--kz-caustics-speed: 1; /* how fast the water moves (0: still water) */
--kz-caustics-calm: 0.3; /* how much the water moves under the text, 0 to 1 */
--kz-caustics-height: 600px;
--kz-caustics-width: 1152px; /* the text column */
--kz-caustics-side: clamp(20px, 5vw, 64px);
position: relative;
isolation: isolate;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: var(--kz-caustics-height);
padding: 120px var(--kz-caustics-side) 72px;
overflow: hidden;
background: var(--kz-caustics-bg);
color: var(--kz-caustics-ink);
}
/* The light: drawn by the script on a canvas. Until then (or without WebGL 2), the floor stays
plain, in the shade */
.kodza-caustics__canvas {
position: absolute;
inset: 0;
z-index: -1;
display: block;
width: 100%;
height: 100%;
color: var(--kz-caustics-light); /* the script reads the light's colour here */
pointer-events: none;
}
.kodza-caustics__content {
width: 100%;
max-width: var(--kz-caustics-width);
margin: 0 auto;
}
/* The temperatures in the corner: the water calms around them too */
.kodza-caustics__note {
position: absolute;
top: 40px;
right: max(var(--kz-caustics-side), calc((100% - var(--kz-caustics-width)) / 2));
margin: 0;
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.4;
}
.kodza-caustics__eyebrow {
margin: 0 0 20px;
color: var(--kz-caustics-muted);
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.4;
}
.kodza-caustics__grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
align-items: end;
gap: 28px 64px;
}
.kodza-caustics__title {
margin: 0;
font-size: clamp(44px, 6.6vw, 92px);
font-weight: 600;
line-height: 0.94;
letter-spacing: -0.055em;
}
.kodza-caustics__line {
display: block;
}
.kodza-caustics__lead {
max-width: 30em;
margin: 0 0 24px;
color: var(--kz-caustics-muted);
font-size: clamp(16px, 1.4vw, 18px);
line-height: 1.5;
text-wrap: pretty;
}
.kodza-caustics__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 0;
}
.kodza-caustics__button {
box-sizing: border-box;
display: inline-flex;
align-items: center;
height: 48px;
padding: 0 22px;
border-radius: 999px;
background: var(--kz-caustics-ink);
color: var(--kz-caustics-on-ink);
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
text-decoration: none;
white-space: nowrap;
transition: opacity 0.18s ease;
}
.kodza-caustics__button:hover {
opacity: 0.86;
}
.kodza-caustics__button--ghost {
background: var(--kz-caustics-bg);
color: inherit;
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 22%, transparent);
}
.kodza-caustics__button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 3px;
}
@media (max-width: 720px) {
.kodza-caustics {
padding: 96px var(--kz-caustics-side) 56px;
}
.kodza-caustics__note {
top: 28px;
}
.kodza-caustics__grid {
grid-template-columns: minmax(0, 1fr);
gap: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-caustics__button {
transition: none;
}
}
/* kodza-guard 24c8eb35b16a — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-caustics__button):not(#_) {
outline: revert;
box-shadow: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-caustics__canvas:not(#_),
.kodza-caustics__content:not(#_),
.kodza-caustics__grid:not(#_),
.kodza-caustics__line:not(#_),
.kodza-caustics__side:not(#_) {
box-sizing: revert;
}
.kodza-caustics__note:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-caustics__eyebrow:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-caustics__title:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-caustics__lead:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-caustics__actions:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-caustics__button:not(#_) {
border: revert;
margin: revert;
list-style: revert;
float: revert;
position: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-style: revert;
line-height: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-caustics__note:not(#_):where(:not([hidden])),
.kodza-caustics__eyebrow:not(#_):where(:not([hidden])),
.kodza-caustics__title:not(#_):where(:not([hidden])),
.kodza-caustics__lead:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-caustics__note:not(#_):where([hidden]),
.kodza-caustics__eyebrow:not(#_):where([hidden]),
.kodza-caustics__title:not(#_):where([hidden]),
.kodza-caustics__lead:not(#_):where([hidden]) {
display: none;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-caustics__note:not(#_) {
position: absolute;
margin: 0;
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.4;
}
.kodza-caustics__eyebrow:not(#_) {
margin: 0 0 20px;
color: var(--kz-caustics-muted);
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.4;
}
.kodza-caustics__title:not(#_) {
margin: 0;
font-size: clamp(44px, 6.6vw, 92px);
font-weight: 600;
line-height: 0.94;
letter-spacing: -0.055em;
}
.kodza-caustics__lead:not(#_) {
max-width: 30em;
margin: 0 0 24px;
color: var(--kz-caustics-muted);
font-size: clamp(16px, 1.4vw, 18px);
line-height: 1.5;
}
.kodza-caustics__actions:not(#_) {
display: flex;
margin: 0;
}
.kodza-caustics__button:not(#_) {
box-sizing: border-box;
display: inline-flex;
padding: 0 22px;
border-radius: 999px;
background: var(--kz-caustics-ink);
color: var(--kz-caustics-on-ink);
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
text-decoration: none;
transition: opacity 0.18s ease;
}
.kodza-caustics__button--ghost:not(#_) {
background: var(--kz-caustics-bg);
color: inherit;
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 22%, transparent);
}
.kodza-caustics__button:focus-visible:not(#_) {
outline: 2px solid currentColor;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.kodza-caustics__button:not(#_) {
transition: none;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Caustics
// The floor of a pool in the sun, behind the hero: the net of bright lines the surface of the
// water gathers on the floor, worked out for real with native WebGL 2 (every point of a surface
// made of two dozen swells sends its ray of sun to the floor, and the light adds up where the
// rays cross); under the text the water calms down. Without WebGL 2, the plain background
// stays.
(() => {
const SELECTOR = '[data-kodza-caustics]';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ---- Kodza shader engine 1.1 -----------------------------------------------------------
// The same copy in every WebGL background of the family: it owns the canvas and its context,
// one triangle over the whole section, the pointer, the text mask and the pauses (off-screen,
// hidden tab, nothing moving, context lost). A background hands it a layer: its fragment
// shader, how its state moves and the uniforms it sets. Change it here, then copy it to the
// others as is.
// layer = { canvas: 'kodza-…__canvas', fragment: '<GLSL>', uniforms: ['u_…'],
// quiet: '<the text over the background>', calm: '--kz-…-calm', idleFps: 30,
// setup(s), enter(s), step(s, dt) → 0 asleep | 1 drifting | 2 moving, draw(s, gl, u) }
// The fragment shader gets u_size (CSS pixels), u_ratio (device pixels per CSS pixel),
// u_ink (the canvas `color`, as rgba), u_quiet and u_quietSize (1 in the open, 0 under a line
// of text, feathered) and u_calm.
// 1.1, a 1.0 layer runs on it unchanged: `webgl2: true` asks for a WebGL 2 context (then
// `vertex` and `fragment` are GLSL 300 es), `prepare(s, gl)` builds whatever else the layer
// draws once the program is in (false: it cannot, the CSS background stays).
const VERTEX = 'attribute vec2 a_position; void main() { gl_Position = vec4(a_position, 0.0, 1.0); }';
const PIXELS = 4.5e6; // device pixels at most: a huge section is drawn a little softer, not slower
const CELL = 8; // the text mask, in CSS pixels per cell
const PAD = 6; // clear space around a line of text, then the feather
const FEATHER = 22;
function shader(root, signal, layer) {
const canvas = document.createElement('canvas');
const gl = canvas.getContext(layer.webgl2 ? 'webgl2' : 'webgl', { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, powerPreference: 'low-power' });
if (!gl) return null; // the still background drawn in CSS stays
const s = {
root, canvas, gl, still: reduceMotion, time: 0, scale: 1, width: 0, height: 0,
ink: [0, 0, 0, 1], calm: 1, pointer: { x: 0, y: 0, active: false },
};
let u = {};
let mask = null;
let maskSize = [CELL, CELL];
let raf = 0;
let last = 0;
let painted = 0;
let onScreen = false;
let shown = !document.hidden;
let entered = false;
let lost = false;
let size = '';
const compile = () => {
if (!layer.webgl2 && !gl.getExtension('OES_standard_derivatives')) return false; // fwidth keeps every line one pixel wide (WebGL 2 has it)
const part = (type, source) => {
const sh = gl.createShader(type);
gl.shaderSource(sh, source);
gl.compileShader(sh);
return gl.getShaderParameter(sh, gl.COMPILE_STATUS) ? sh : null;
};
const vs = part(gl.VERTEX_SHADER, layer.vertex || VERTEX);
const fs = part(gl.FRAGMENT_SHADER, layer.fragment);
if (!vs || !fs) return false;
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.bindAttribLocation(program, 0, 'a_position');
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return false;
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); // one triangle over the whole canvas
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
u = {};
['u_size', 'u_ratio', 'u_ink', 'u_quiet', 'u_quietSize', 'u_calm', ...(layer.uniforms || [])].forEach((name) => {
u[name] = gl.getUniformLocation(program, name);
});
mask = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, mask);
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.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, 1, 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, new Uint8Array([255]));
gl.uniform1i(u.u_quiet, 0);
return layer.prepare?.(s, gl) !== false;
};
if (!compile()) return null;
canvas.className = layer.canvas;
canvas.setAttribute('aria-hidden', 'true');
root.prepend(canvas);
root.classList.add('is-live');
// The ink is whatever the CSS says (a theme colour, color-mix…): a one-pixel canvas turns it into rgba
const probe = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
probe.canvas.width = 1;
probe.canvas.height = 1;
const colors = () => {
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = '#000';
probe.fillStyle = getComputedStyle(canvas).color;
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
s.ink = [r / 255, g / 255, b / 255, a / 255];
};
// Screen pixels to the root's own: a parent scaled by transform must not shift the hand
const scaleOf = () => root.getBoundingClientRect().width / root.offsetWidth || 1;
// Under the text the background calms down (1 in the open, 0 under a line of text), so the
// headline stays readable. A small texture of 8-pixel cells, smoothed by the GPU: never a box
const quietZones = () => {
const calm = parseFloat(getComputedStyle(root).getPropertyValue(layer.calm));
s.calm = Number.isFinite(calm) ? Math.min(Math.max(calm, 0), 1) : 1;
const cols = Math.ceil(s.width / CELL) + 1;
const rows = Math.ceil(s.height / CELL) + 1;
const data = new Uint8Array(cols * rows).fill(255);
if (layer.quiet && s.calm < 1) {
const box = root.getBoundingClientRect();
const k = box.width / root.offsetWidth || 1;
const range = document.createRange();
const lines = [];
// The lines of text only: a block inside (a span per line) is as wide as its column
root.querySelectorAll(layer.quiet).forEach((el) => {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (!node.data.trim()) continue;
range.selectNodeContents(node);
for (const r of range.getClientRects()) {
if (r.width && r.height) lines.push([(r.left - box.left) / k, (r.top - box.top) / k, (r.right - box.left) / k, (r.bottom - box.top) / k]);
}
}
});
for (let r = 0, i = 0; r < rows; r++) {
const y = (r + 0.5) * CELL;
for (let c = 0; c < cols; c++, i++) {
const x = (c + 0.5) * CELL;
let near = Infinity;
for (const [l, t, rt, b] of lines) near = Math.min(near, Math.hypot(Math.max(l - x, 0, x - rt), Math.max(t - y, 0, y - b)));
const q = Math.min(Math.max((near - PAD) / FEATHER, 0), 1);
data[i] = Math.round(255 * q * q * (3 - 2 * q));
}
}
}
maskSize = [cols * CELL, rows * CELL];
gl.bindTexture(gl.TEXTURE_2D, mask);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, cols, rows, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data);
};
const build = () => {
const w = root.offsetWidth;
const h = root.offsetHeight;
if (!w || !h || lost) return false;
size = `${w}x${h}`;
s.scale = scaleOf();
const ratio = Math.max(Math.min((window.devicePixelRatio || 1) * s.scale, 2, Math.sqrt(PIXELS / (w * h))), 1);
canvas.width = Math.round(w * ratio);
canvas.height = Math.round(h * ratio);
gl.viewport(0, 0, canvas.width, canvas.height);
s.width = w;
s.height = h;
colors();
quietZones();
layer.setup?.(s);
return true;
};
const paint = () => {
if (lost || !s.width) return;
gl.uniform2f(u.u_size, s.width, s.height);
gl.uniform1f(u.u_ratio, canvas.width / s.width);
gl.uniform4f(u.u_ink, ...s.ink);
gl.uniform2f(u.u_quietSize, ...maskSize);
gl.uniform1f(u.u_calm, s.calm);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, mask);
layer.draw(s, gl, u);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const running = () => onScreen && shown && !s.still && !lost;
function wake() {
if (raf || !running()) return;
if (Math.abs(scaleOf() - s.scale) > 0.01) build(); // zoomed since: redraw at the new sharpness
colors(); // the theme may have changed while asleep
last = performance.now();
raf = requestAnimationFrame(frame);
}
function frame(now) {
raf = 0;
if (signal.aborted || !running()) return;
const dt = Math.min(Math.max((now - last) / 1000, 0), 1 / 30); // a frame stamp can predate the wake
last = now;
s.time += dt;
const mode = layer.step(s, dt);
// Moving: every frame. Drifting: a few pixels a second, 30 frames are plenty. Asleep: the last one
if (mode !== 1 || now - painted >= 1000 / (layer.idleFps || 30) - 4) {
paint();
painted = now;
}
if (mode) raf = requestAnimationFrame(frame);
}
if (!s.still) {
// The hand, or a finger until the page takes it for a scroll
const move = (e) => {
const box = root.getBoundingClientRect();
const k = box.width / root.offsetWidth || 1;
Object.assign(s.pointer, { x: (e.clientX - box.left) / k, y: (e.clientY - box.top) / k, active: true });
wake();
};
const leave = () => {
s.pointer.active = false;
wake();
};
root.addEventListener('pointermove', move, { signal });
root.addEventListener('pointerdown', move, { signal });
root.addEventListener('pointerleave', leave, { signal });
root.addEventListener('pointercancel', leave, { signal });
}
const io = new IntersectionObserver((entries) => {
onScreen = entries[entries.length - 1].isIntersecting;
if (onScreen && !entered && !s.still) {
entered = true;
layer.enter?.(s);
}
wake();
});
io.observe(root);
document.addEventListener('visibilitychange', () => {
shown = !document.hidden;
wake();
}, { signal });
const ro = new ResizeObserver(() => {
if (`${root.offsetWidth}x${root.offsetHeight}` !== size && build()) paint();
});
ro.observe(root);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
colors();
paint();
}, { signal });
// The GPU can take the context back (a driver reset, too many tabs): the browser offers it
// again if asked, and everything is rebuilt on the new one
canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault();
lost = true;
cancelAnimationFrame(raf);
raf = 0;
}, { signal });
canvas.addEventListener('webglcontextrestored', () => {
lost = false;
if (!compile()) {
lost = true;
canvas.remove();
root.classList.remove('is-live'); // back to the still CSS background
return;
}
if (build()) paint();
wake();
}, { signal });
signal.addEventListener('abort', () => {
cancelAnimationFrame(raf);
io.disconnect();
ro.disconnect();
gl.getExtension('WEBGL_lose_context')?.loseContext(); // a page gets a dozen contexts at most: give this one back now
canvas.width = 0;
canvas.height = 0;
canvas.remove();
});
if (build()) paint();
// The text moves once the web font is in: calm the background under its final lines
document.fonts?.ready.then(() => {
if (signal.aborted || !s.width || lost) return;
quietZones();
paint();
});
return s;
}
// ---- end of the shader engine ----------------------------------------------------------
// The water: two dozen swells of different lengths, most of them from one side like a wind,
// summed. Every point of the surface sends its ray of sun to the floor, bent by the slope under
// it; where the rays crowd, the floor gets more light. That is the net of bright lines. The
// first time it is seen, the water is flat (an even light), then the swell rises and the light
// gathers into lines.
const WAVES = 24;
const FOCUS = 1.05; // how sharply the swell gathers the light: more, and the lines double
const SWELL = 0.42; // how fast the swell moves
const GLOW = 0.9; // seconds for the light to come in
const RISE = 1.6; // seconds for the swell to rise
const SPACING = 2; // CSS pixels between two rays
const RAYS = 3e5; // rays at most: a huge section gets them a little further apart
const STILL = 3; // the moment shown when motion is reduced
const smooth = (t) => {
const k = Math.min(Math.max(t, 0), 1);
return k * k * (3 - 2 * k);
};
// The same water every time: the swells come from a seeded draw
const water = (() => {
let seed = 11;
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const waves = new Float32Array(WAVES * 4);
const phase = new Float32Array(WAVES);
let curve = 0;
let slope = 0;
for (let i = 0; i < WAVES; i++) {
const length = 0.11 * Math.pow(0.5 / 0.11, rnd()); // from a ripple to a long swell, in waves units
const k = 2 * Math.PI / length;
const angle = 0.7 + (rnd() + rnd() + rnd() - 1.5) * 1.4;
const amp = Math.pow(length, 1.6);
waves.set([Math.cos(angle) * k, Math.sin(angle) * k, amp, SWELL * Math.sqrt(k)], i * 4);
phase[i] = rnd() * 6.283;
curve += (amp * k * k) ** 2 / 2;
slope += amp * k;
}
// The swell is scaled by how much it bends the light, not by its height
const norm = FOCUS / Math.sqrt(curve);
for (let i = 0; i < WAVES; i++) waves[i * 4 + 2] *= norm;
// How far a ray can land from where it fell: the grid of rays goes that far
// past the edges, so the light at the edges is as full as in the middle
return { waves, phase, reach: Math.min(0.25, slope * norm * 0.6) + 0.12 };
})();
// Pass 1: one ray per point of a grid laid on the surface. Each triangle of the grid lands on
// the floor bent out of shape; the light it brings is the surface it covered over the floor it
// covers, added up where triangles overlap
const RAYS_VERTEX = `#version 300 es
precision highp float;
in vec2 a_position;
out vec2 v_from;
uniform vec4 u_waves[${WAVES}];
uniform float u_phase[${WAVES}];
uniform float u_time;
uniform float u_rise;
uniform vec2 u_size;
uniform float u_unit;
// The slope of the swell at a point of the surface: where its ray of sun is bent to
vec2 swell(vec2 q) {
vec2 g = vec2(0.0);
for (int i = 0; i < ${WAVES}; i++) {
vec4 w = u_waves[i];
g += w.z * w.xy * cos(dot(w.xy, q) - w.w * u_time + u_phase[i]);
}
return g * u_rise;
}
void main() {
v_from = a_position;
vec2 x = a_position + swell(a_position);
vec2 css = x * u_unit;
gl_Position = vec4(css.x / u_size.x * 2.0 - 1.0, 1.0 - css.y / u_size.y * 2.0, 0.0, 1.0);
}`;
const RAYS_FRAGMENT = `#version 300 es
precision highp float;
in vec2 v_from;
out vec4 o;
uniform float u_area;
uniform float u_store;
void main() {
vec2 a = dFdx(v_from);
vec2 b = dFdy(v_from);
o = vec4(min(abs(a.x * b.y - a.y * b.x) * u_area, 40.0) * u_store);
}`;
const program = (gl, vertex, fragment) => {
const part = (type, source) => {
const sh = gl.createShader(type);
gl.shaderSource(sh, source);
gl.compileShader(sh);
return gl.getShaderParameter(sh, gl.COMPILE_STATUS) ? sh : null;
};
const vs = part(gl.VERTEX_SHADER, vertex);
const fs = part(gl.FRAGMENT_SHADER, fragment);
if (!vs || !fs) return null;
const p = gl.createProgram();
gl.attachShader(p, vs);
gl.attachShader(p, fs);
gl.bindAttribLocation(p, 0, 'a_position');
gl.linkProgram(p);
return gl.getProgramParameter(p, gl.LINK_STATUS) ? p : null;
};
// The grid of rays, a ray every 2 CSS pixels, in waves units
const mesh = (s) => {
const { gl, rays } = s;
const key = `${s.width}x${s.height}@${s.unit}`;
if (rays.key === key) return;
rays.key = key;
const { reach } = water;
const w = s.width / s.unit + 2 * reach;
const h = s.height / s.unit + 2 * reach;
const step = Math.max(SPACING / s.unit, Math.sqrt(w * h / RAYS));
const nx = Math.ceil(w / step) + 1;
const ny = Math.ceil(h / step) + 1;
const points = new Float32Array(nx * ny * 2);
for (let j = 0, n = 0; j < ny; j++) {
for (let i = 0; i < nx; i++, n += 2) {
points[n] = i * step - reach;
points[n + 1] = j * step - reach;
}
}
// A strip of triangles per row, cut by the restart index WebGL 2 always honours
const index = new Uint32Array((ny - 1) * (2 * nx + 1));
for (let j = 0, n = 0; j < ny - 1; j++) {
for (let i = 0; i < nx; i++) {
index[n++] = j * nx + i;
index[n++] = (j + 1) * nx + i;
}
index[n++] = 0xffffffff;
}
gl.bindVertexArray(rays.vao);
gl.bindBuffer(gl.ARRAY_BUFFER, rays.points);
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, rays.index);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, index, gl.STATIC_DRAW);
gl.bindVertexArray(null);
rays.count = index.length;
};
// Where the light adds up: a one-channel texture the size of the canvas, in half floats where
// the GPU can draw them (8 bits otherwise, scaled down so it does not clip), drawn through 4×
// antialiasing so a thin line does not shimmer. Its smaller copies give the calm water its
// wide average
const targets = (s) => {
const { gl, canvas: { width, height } } = s;
const t = s.light;
gl.deleteFramebuffer(t.msFb);
gl.deleteRenderbuffer(t.msaa);
gl.deleteFramebuffer(t.fb);
gl.deleteTexture(t.texture);
for (const format of s.half ? [gl.R16F, gl.R8] : [gl.R8]) {
t.texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t.texture);
gl.texStorage2D(gl.TEXTURE_2D, Math.min(7, Math.floor(Math.log2(Math.max(width, height))) + 1), format, width, height);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_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);
t.fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, t.fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t.texture, 0);
let ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
const samples = [...(gl.getInternalformatParameter(gl.RENDERBUFFER, format, gl.SAMPLES) || [])].find((n) => n <= 4) || 0;
t.msaa = null;
t.msFb = null;
if (ok && samples) {
t.msaa = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, t.msaa);
gl.renderbufferStorageMultisample(gl.RENDERBUFFER, samples, format, width, height);
t.msFb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, t.msFb);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.RENDERBUFFER, t.msaa);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
gl.deleteFramebuffer(t.msFb);
gl.deleteRenderbuffer(t.msaa);
t.msaa = null;
t.msFb = null;
}
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
if (ok) {
t.store = format === gl.R8 ? 0.125 : 1;
break;
}
gl.deleteFramebuffer(t.fb);
gl.deleteTexture(t.texture);
}
};
const caustics = {
canvas: 'kodza-caustics__canvas',
quiet: '.kodza-caustics__content',
calm: '--kz-caustics-calm',
idleFps: 30,
webgl2: true,
vertex: `#version 300 es
in vec2 a_position;
void main() { gl_Position = vec4(a_position, 0.0, 1.0); }`,
uniforms: ['u_light', 'u_store', 'u_strength', 'u_glow'],
// Pass 2, over the whole section: the floor gets the light that landed on it
fragment: `#version 300 es
precision highp float;
uniform vec2 u_size;
uniform float u_ratio;
uniform vec4 u_ink;
uniform sampler2D u_quiet;
uniform vec2 u_quietSize;
uniform float u_calm;
uniform sampler2D u_light;
uniform float u_store;
uniform float u_strength;
uniform float u_glow;
out vec4 o;
#define EVEN 24.0 // the width of the calm water's average, in CSS pixels
#define MEAN 0.85 // the open water's mean brightness, on the flat water's scale
#define PEAK 1.0 // the calm water's brightest, on the same scale: no bright line under the text
float at(vec2 d) {
return textureLod(u_light, (gl_FragCoord.xy + d * u_ratio * 0.75) / vec2(textureSize(u_light, 0)), 0.0).r;
}
void main() {
// The sun is not a point: the light is a little soft
float s = at(vec2(0.0)) * 0.36
+ (at(vec2(1.0, 0.0)) + at(vec2(-1.0, 0.0)) + at(vec2(0.0, 1.0)) + at(vec2(0.0, -1.0))) * 0.12
+ (at(vec2(1.0)) + at(vec2(-1.0)) + at(vec2(1.0, -1.0)) + at(vec2(-1.0, 1.0))) * 0.04;
float lit = 1.0 - exp(-s / u_store * u_strength);
// Under the text the water calms down: its light evens out to a wide average of the floor,
// brought to the open water's mean brightness so it never shows as a patch
vec2 css = vec2(gl_FragCoord.x, u_size.y * u_ratio - gl_FragCoord.y) / u_ratio;
float keep = mix(u_calm, 1.0, texture(u_quiet, css / u_quietSize).r);
if (keep < 1.0) {
float even = textureLod(u_light, gl_FragCoord.xy / vec2(textureSize(u_light, 0)), log2(EVEN * u_ratio)).r;
lit = mix(1.0 - exp(-min(even / u_store, PEAK) * u_strength * MEAN), lit, keep);
}
float a = lit * u_glow * u_ink.a;
o = vec4(u_ink.rgb * a, a);
}`,
prepare(s, gl) {
const p = program(gl, RAYS_VERTEX, RAYS_FRAGMENT);
if (!p) return false;
const u = {};
['u_waves', 'u_phase', 'u_time', 'u_rise', 'u_size', 'u_unit', 'u_area', 'u_store'].forEach((name) => {
u[name] = gl.getUniformLocation(p, name);
});
const main = gl.getParameter(gl.CURRENT_PROGRAM);
gl.useProgram(p);
gl.uniform4fv(u.u_waves, water.waves);
gl.uniform1fv(u.u_phase, water.phase);
gl.useProgram(main);
s.rays = { program: p, main, u, vao: gl.createVertexArray(), points: gl.createBuffer(), index: gl.createBuffer(), count: 0, key: '' };
gl.bindVertexArray(s.rays.vao);
gl.bindBuffer(gl.ARRAY_BUFFER, s.rays.points);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, s.rays.index);
gl.bindVertexArray(null);
s.half = !!gl.getExtension('EXT_color_buffer_float');
s.light = {};
s.clock ??= s.still ? STILL : 0;
return true;
},
setup(s) {
const css = getComputedStyle(s.root);
const read = (name, fallback, min, max) => {
const value = parseFloat(css.getPropertyValue(`--kz-caustics-${name}`));
return Math.min(Math.max(Number.isFinite(value) ? value : fallback, min), max);
};
s.strength = read('strength', 0.55, 0, 3);
s.unit = read('scale', 480, 120, 4000);
s.speed = read('speed', 1, 0, 10);
mesh(s);
targets(s);
},
enter(s) {
s.born = s.time;
},
step(s, dt) {
if (s.born === undefined) return 0;
s.clock += dt * s.speed;
return s.speed > 0 || s.time - s.born < RISE ? 1 : 0;
},
draw(s, gl, u) {
const since = s.born === undefined ? (s.still ? Infinity : 0) : s.time - s.born;
const { rays, light } = s;
const ratio = s.canvas.width / s.width;
gl.bindFramebuffer(gl.FRAMEBUFFER, light.msFb || light.fb);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(rays.program);
gl.uniform1f(rays.u.u_time, s.clock);
gl.uniform1f(rays.u.u_rise, smooth(since / RISE));
gl.uniform2f(rays.u.u_size, s.width, s.height);
gl.uniform1f(rays.u.u_unit, s.unit);
gl.uniform1f(rays.u.u_area, (s.unit * ratio) ** 2);
gl.uniform1f(rays.u.u_store, light.store);
gl.bindVertexArray(rays.vao);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
gl.drawElements(gl.TRIANGLE_STRIP, rays.count, gl.UNSIGNED_INT, 0);
gl.disable(gl.BLEND);
gl.bindVertexArray(null);
if (light.msFb) {
gl.bindFramebuffer(gl.READ_FRAMEBUFFER, light.msFb);
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, light.fb);
gl.blitFramebuffer(0, 0, s.canvas.width, s.canvas.height, 0, 0, s.canvas.width, s.canvas.height, gl.COLOR_BUFFER_BIT, gl.NEAREST);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.useProgram(rays.main);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, light.texture);
gl.generateMipmap(gl.TEXTURE_2D);
gl.activeTexture(gl.TEXTURE0);
gl.uniform1i(u.u_light, 1);
gl.uniform1f(u.u_store, light.store);
gl.uniform1f(u.u_strength, s.strength);
gl.uniform1f(u.u_glow, smooth(since / GLOW));
},
};
// One instance, with its own state: two copies on a page never interfere. Everything it
// starts is tied to `signal` (listeners get { signal }, observers and timers stop on its
// 'abort' event), so kodza:destroy stops it all.
function mount(root, signal) {
shader(root, signal, caustics);
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load. Each running root
// keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
// component inside another one is saved as written, not half-started, and stopped with it.
const KODZA = Symbol.for('kodza');
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
const put = (el, saved) => {
[...el.attributes].forEach((a) => el.removeAttribute(a.name));
saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
el.innerHTML = saved.html;
};
// The markup inside a root as written: Kodza components already running in it give back their own
const written = (root) => {
const all = [...root.querySelectorAll('*')];
if (!all.some((el) => el[KODZA])) return root.innerHTML;
const copy = root.cloneNode(true);
const twins = copy.querySelectorAll('*');
let last = null;
all.forEach((el, i) => {
if (!el[KODZA] || last?.contains(el)) return;
last = el;
put(twins[i], el[KODZA]);
});
return copy.innerHTML;
};
const init = (scope) => find(scope).forEach((root) => {
if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
const controller = new AbortController();
const saved = { html: written(root), attrs: attrsOf(root), stop: () => destroy(root) };
instances.set(root, { controller, saved });
root[KODZA] = saved;
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
// The Kodza components running inside it stop first: once its markup is put back, their own
// script could not find them any more
root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
instance.controller.abort();
delete root[KODZA];
put(root, instance.saved);
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Caustics, for React. From https://kodza.dev/components/caustics/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: caustics.css and caustics.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './caustics.css';
export type CausticsProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** the floor in the shade — default: #babab6 */
"--kz-caustics-bg"?: string | number;
/** the light on it — default: #fff */
"--kz-caustics-light"?: string | number;
/** how much light reaches the floor, 0 to 1 — default: 0.55 */
"--kz-caustics-strength"?: string | number;
/** the text and the button — default: #0a0a0a */
"--kz-caustics-ink"?: string | number;
/** default: rgba(10, 10, 10, 0.7) */
"--kz-caustics-muted"?: string | number;
/** the button's label — default: #fff */
"--kz-caustics-on-ink"?: string | number;
/** the size of the waves — default: 480px */
"--kz-caustics-scale"?: string | number;
/** how fast the water moves (0: still water) — default: 1 */
"--kz-caustics-speed"?: string | number;
/** how much the water moves under the text, 0 to 1 — default: 0.3 */
"--kz-caustics-calm"?: string | number;
/** default: 600px */
"--kz-caustics-height"?: string | number;
/** the text column — default: 1152px */
"--kz-caustics-width"?: string | number;
/** default: clamp(20px, 5vw, 64px) */
"--kz-caustics-side"?: string | number;
};
className?: string;
};
export function Caustics({ vars, className, style, ...rest }: CausticsProps) {
const ref = useRef<ComponentRef<'section'>>(null);
const classes = ["kodza-caustics", className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, vars, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./caustics.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
return (
<section data-kodza-caustics="" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<div className="kodza-caustics__content">
<p className="kodza-caustics__note">Pool 29 °C · Channel 14 °C</p>
<p className="kodza-caustics__eyebrow">{"Hotel & baths · Saint-Malo · open from April"}</p>
<div className="kodza-caustics__grid">
<h1 className="kodza-caustics__title">
<span className="kodza-caustics__line">Seven rooms.</span>
{' '}
<span className="kodza-caustics__line">One sea.</span>
</h1>
<div className="kodza-caustics__side">
<p className="kodza-caustics__lead">Seven rooms on the ramparts, a pool on the roof, and the Channel right below. The pool is heated. The Channel isn't.</p>
<p className="kodza-caustics__actions">
<a className="kodza-caustics__button" href="#">Book a room</a>
{' '}
<a className="kodza-caustics__button kodza-caustics__button--ghost" href="#">See the pool</a>
</p>
</div>
</div>
</div>
</section>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.