# Caustics

> A 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.

Backgrounds & textures · background, webgl, shader, water, caustics, pool, hero · HTML, CSS, vanilla JS · no dependencies · updated 2026-10-26
Demo: https://kodza.dev/components/caustics/

## Files

The code below, served as files: byte for byte what the Kodza site copies (the CSS includes its theme guard).

- [`kodza-caustics.css`](https://kodza.dev/registry/caustics/kodza-caustics.css) · 10.7 KB
- [`kodza-caustics.js`](https://kodza.dev/registry/caustics/kodza-caustics.js) · 28.6 KB
- [`kodza-caustics.html`](https://kodza.dev/registry/caustics/kodza-caustics.html) · 0.9 KB · the markup, to adapt and paste into your page

**Can you run shell commands? Download the CSS and the JS, don't retype them.** Retyping costs thousands of tokens and can drop or change lines of the CSS. Put the files where your stack expects them (Use it says where: change the `-o` paths):

```bash
curl -fsSL --create-dirs -o kodza/kodza-caustics.css https://kodza.dev/registry/caustics/kodza-caustics.css
curl -fsSL --create-dirs -o kodza/kodza-caustics.js https://kodza.dev/registry/caustics/kodza-caustics.js
```

Then load the files from your page as Use it shows, instead of pasting the content. The HTML is the part you adapt (text, links, modifiers): paste it from the Code section. Copy the CSS and the JS from the Code section only if you can't run commands, exactly as they are. (Windows PowerShell: `curl.exe`.)

## API

- Root: `[data-kodza-caustics]`
- Variables (set them on the root element itself: it declares its own defaults):
  - `--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)`
- Attributes:
  - `data-kodza-caustics`
- Lifecycle: `kodza:init` / `kodza:destroy` on `document`, with `{ detail: { root } }` to target a container.

## Dark mode

The values Kodza uses on a dark background (`#0a0a0a`). For a page that follows the system theme, add them after the component's CSS:

```css
@media (prefers-color-scheme: dark) {
  .kodza-caustics {
    --kz-caustics-bg: #0a0a0a;
    --kz-caustics-light: #c2c2bf;
    --kz-caustics-strength: 0.45;
    --kz-caustics-ink: #f2f2f0;
    --kz-caustics-muted: rgba(242, 242, 240, 0.78);
    --kz-caustics-on-ink: #0a0a0a;
  }
}
```

A site with its own theme switch puts the same rules under its dark selector instead (`[data-theme="dark"] .kodza-caustics { … }`); a single dark section, under that section's class.

## Use it

### Plain HTML page

- CSS: `kodza/kodza-caustics.css` next to the page (the Files commands as they are), linked in `<head>`: `<link rel="stylesheet" href="kodza/kodza-caustics.css">`. Without a shell, paste it in a `<style>` in `<head>` instead. It only touches `kodza-` classes.
- HTML: where the component goes. Paste it as many times as you need: instances never interfere.
- JS: `kodza/kodza-caustics.js`, loaded with `<script src="kodza/kodza-caustics.js" defer></script>` in `<head>`. Without a shell, paste it in a `<script>` before `</body>` (or in `<head>`: it waits for the page). One copy per page, whatever the number of instances.
- Restyle with the `--kz-…` variables, set on the component's root element itself (the root declares its own defaults, so a value on an ancestor is ignored), and with the modifiers / attributes listed under API.
- Font: it takes the page's own. The Kodza look is Geist: `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap">`.
- Content added later (AJAX navigation, a SPA route, a builder preview): start it with `document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: container } }))`; before removing it, the same call with `'kodza:destroy'` stops it and puts the original markup back.

### Claude artifact (claude.ai)

- Paste the CSS in a `<style>` and the JS in a `<script>` at the end, in the artifact itself: it loads stylesheets only from Google Fonts and scripts only from a few CDNs, so no `<link>` or `<script src>` to Kodza.
- Writing the artifact file with a shell? Insert the downloaded files (Files) with a command instead of retyping them: a line `KODZA-CSS` alone inside the `<style>`, `KODZA-JS` inside the `<script>`, then `sed -e '/^KODZA-CSS$/{r kodza/kodza-caustics.css' -e 'd' -e '}' -e '/^KODZA-JS$/{r kodza/kodza-caustics.js' -e 'd' -e '}' page.html > artifact.html`.
- Font: load Geist from Google Fonts (allowed): `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap">`.
- Dark mode: the viewer sets `data-theme="dark"` or `"light"` on `<html>`, or nothing when it follows the system. Write the Dark mode rules twice: inside `@media (prefers-color-scheme: dark)` with `:root:not([data-theme="light"])` in front of each selector, and with `:root[data-theme="dark"]` in front of each selector.

### WordPress

- HTML: a **Custom HTML** block (or a pattern / template part). WordPress keeps this markup as it is only for users allowed to post unfiltered HTML (Administrators; Super Admins on multisite): for anyone else it strips tags such as `<svg>`, `<input>` or `<dialog>`, so add it from the theme (a pattern or template part) instead.
- This one spans the page: a block theme keeps a Custom HTML block to the width of the text column, so put it inside a **Group** block set to **Full width**, with **Inner blocks use content width** turned off.
- CSS and JS: the downloaded files go in the theme, under `wp-content/themes/<your-theme>/kodza/` (the child theme if there is one), enqueued from its functions.php (or a code-snippets plugin):
  ```php
  add_action('wp_enqueue_scripts', function () {
    $dir = get_stylesheet_directory() . '/kodza/';
    $uri = get_stylesheet_directory_uri() . '/kodza/';
    wp_enqueue_style('kodza-caustics', $uri . 'kodza-caustics.css', [], filemtime($dir . 'kodza-caustics.css'));
    wp_enqueue_script('kodza-caustics', $uri . 'kodza-caustics.js', [], filemtime($dir . 'kodza-caustics.js'), true);
  });
  ```
  Scoped to `kodza-` classes, the CSS never touches the theme. One copy of the JS, whatever the number of blocks.
- No access to the theme files? The CSS can also go, as it is, in **Appearance → Customize → Additional CSS** (classic theme) or **Styles → Additional CSS** in the Site Editor (block theme).
- Theme with AJAX page transitions: fire `kodza:init` on the new content after each swap (and `kodza:destroy` on the old one).
- Performance plugin (WP Rocket, LiteSpeed Cache, Perfmatters…) with **Delay JavaScript execution** turned on: add `kodza` to the scripts it must not delay. Otherwise the component waits for the visitor's first scroll, tap or mouse move, and that first click can be lost.
- Colours: by default, match the theme's palette. After the component's CSS, point these variables at the theme's colours, on the component root, Kodza's value as the fallback:
  `.kodza-… { --kz-caustics-ink: var(ACCENT, #0a0a0a); --kz-caustics-on-ink: var(BACKGROUND, #fff); }`
  TEXT, BACKGROUND and ACCENT (the colour the theme fills its own buttons with) are the theme's own colour variables — block themes (theme.json, like Twenty Twenty-Five): `--wp--preset--color--contrast`, `--wp--preset--color--base`, `--wp--preset--color--contrast` (they fill their buttons with it; their accent-1… colours are decorative); Astra: `--ast-global-color-2`, `--ast-global-color-4`, `--ast-global-color-0`; Kadence: `--global-palette3`, `--global-palette9`, `--global-palette1`; GeneratePress: `--contrast`, `--base-3`, `--accent`; Blocksy: `--theme-palette-color-4`, `--theme-palette-color-8`, `--theme-palette-color-1`. Use only these names, and only if the site really has them (the theme's CSS, or Styles → Colors): the `--wp--preset--color--black`, `--white`, `--vivid-…` that WordPress adds everywhere are not the theme's palette. Another theme, or one without these variables (Hello Elementor, OceanWP): leave the variables alone, it keeps Kodza's monochrome; same when the developer asks for monochrome or the Kodza look. Leave the translucent greys (`rgba(10, 10, 10, …)`) as they are: they suit any light background. On a dark palette, set the other variables from Dark mode too.
- WordPress turns straight quotes and `--` into typographic ones in the text of a Custom HTML block (not inside `<code>` or `<pre>`).
- Put it in a full-width Group. Your own text goes inside __content (the water calms down under every line of it); set --kz-caustics-bg and --kz-caustics-light to your theme's colours, and --kz-caustics-speed to 0 for still water.

#### With Elementor

- HTML: an **HTML** widget (Elementor shows it to administrators only). It keeps the code exactly as pasted, straight quotes included.
- This one spans the page: give the widget its own container, **Content Width: Full Width**, padding 0.
- CSS and JS: as above. Elementor's Site Settings → Theme Style styles every button, field, link and image of the page, components included; the theme guard at the end of the CSS keeps the component's buttons, fields and link colours as they are.
- The Elementor editor redraws each widget after the page has loaded: without the script below, the component stays still in the editor preview (the published page works). Add it once per site, in the footer like the component's script (`kodza/elementor-editor.js`, or a code-snippets plugin):

```js
// Kodza in the Elementor editor: the editor redraws each HTML widget after the page has loaded.
// Start Kodza in what it draws, stop it in what it replaces or removes. Once per site.
window.addEventListener('elementor/frontend/init', () => {
  if (!elementorFrontend.isEditMode()) return;
  class KodzaWidget extends elementorModules.frontend.handlers.Base {
    onInit() {
      super.onInit();
      this.kodza = [...this.$element[0].children];
      document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: this.$element[0] } }));
    }
    onDestroy() {
      super.onDestroy();
      this.kodza.forEach((root) => document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } })));
    }
  }
  elementorFrontend.elementsHandler.attachHandler('html', KodzaWidget);
});
```

- No child theme: everything can go in the HTML widget instead, `<style>` with the CSS, the HTML, then `<script>` with the JS. It works on the page and in the editor without the script above.

### Shopify

- Files: `assets/kodza-caustics.css` and `assets/kodza-caustics.js` (the Files commands with `-o assets/…`).
- In the section: `{{ 'kodza-caustics.css' | asset_url | stylesheet_tag }}` and `<script src="{{ 'kodza-caustics.js' | asset_url }}" defer></script>`, then the HTML.
- The theme editor re-renders sections: restart them with
  `document.addEventListener('shopify:section:load', (e) => document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: e.target } })));`
  and the same with `shopify:section:unload` → `kodza:destroy`.

### React / Next.js

- Files, side by side (e.g. in `components/kodza/`): `Caustics.tsx` (below), the CSS block under Code saved as `caustics.css` and the JS block as `caustics.js`, both as they are. The HTML block is already inside the component: skip it.
- Use it: `import { Caustics } from '@/components/kodza/Caustics';` then `<Caustics />`. Props: `vars` (the `--kz-…` variables listed under API, e.g. `vars={{ '--kz-caustics-bg': … }}`), `className`, `style` and the other props of its `<section>` (`onClick`, `aria-…`).
- Next.js App Router: it is a Client Component already (`'use client'`). Pages Router: Next.js takes global CSS only in `pages/_app`, so move the `import './caustics.css'` line there.
- Safe under StrictMode, client-side navigation and prop changes: when a prop changes, the `key` gives React a fresh element, `kodza:destroy` stops the script on the old one and `kodza:init` starts it on the new one.
- Built from your data (tabs, rows, options)? Add what the markup is made of to the `key`, as plain values (ids, text, numbers). What the visitor changes (the open tab, the sort, what they type) is the script's: 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). Fields stay uncontrolled (`defaultValue`, `name`).

```tsx
'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>
  );
}
```

## Code

```html
<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 &amp; 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>
```

```css
/* 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;
  }
}
```

```js
// 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 });
})();
```

Free to use, from Kodza (https://kodza.dev).
