# Topographic Lines

> A hero over a contour map drawn by a native WebGL shader: the lines drift very slowly, the pointer raises a hill under it, and they thin out under the text. Plain CSS rings without WebGL.

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

## Files

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

- [`kodza-topographic-lines.css`](https://kodza.dev/registry/topographic-lines/kodza-topographic-lines.css) · 12.6 KB
- [`kodza-topographic-lines.js`](https://kodza.dev/registry/topographic-lines/kodza-topographic-lines.js) · 21.2 KB
- [`kodza-topographic-lines.html`](https://kodza.dev/registry/topographic-lines/kodza-topographic-lines.html) · 1.1 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-topographic-lines.css https://kodza.dev/registry/topographic-lines/kodza-topographic-lines.css
curl -fsSL --create-dirs -o kodza/kodza-topographic-lines.js https://kodza.dev/registry/topographic-lines/kodza-topographic-lines.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-topographic-lines]`
- Variables (set them on the root element itself: it declares its own defaults):
  - `--kz-topographic-lines-bg: #f2f2f0` — the paper
  - `--kz-topographic-lines-ink: #0a0a0a` — the lines, the text, the button
  - `--kz-topographic-lines-muted: rgba(10, 10, 10, 0.62)`
  - `--kz-topographic-lines-minor: 0.16` — opacity of a contour line, 0 to 1
  - `--kz-topographic-lines-major: 0.34` — every fifth line, the index contour, a little thicker
  - `--kz-topographic-lines-levels: 12` — how many lines a hill gets: more for steeper ground
  - `--kz-topographic-lines-scale: 520px` — the size of the hills
  - `--kz-topographic-lines-speed: 1` — how fast the ground drifts (0: a still map)
  - `--kz-topographic-lines-lift: 0.5` — how high the pointer raises the ground (0: not at all)
  - `--kz-topographic-lines-reach: 140px` — the radius of that hill
  - `--kz-topographic-lines-calm: 0.2` — how much of a line shows under the text, 0 to 1
  - `--kz-topographic-lines-height: 600px`
  - `--kz-topographic-lines-width: 1152px` — the text column
  - `--kz-topographic-lines-side: clamp(20px, 5vw, 64px)`
- Attributes:
  - `data-kodza-topographic-lines`
- 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-topographic-lines {
    --kz-topographic-lines-bg: #0a0a0a;
    --kz-topographic-lines-ink: #f2f2f0;
    --kz-topographic-lines-muted: rgba(242, 242, 240, 0.62);
    --kz-topographic-lines-minor: 0.13;
    --kz-topographic-lines-major: 0.28;
  }
}
```

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

## Use it

### Plain HTML page

- CSS: `kodza/kodza-topographic-lines.css` next to the page (the Files commands as they are), linked in `<head>`: `<link rel="stylesheet" href="kodza/kodza-topographic-lines.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-topographic-lines.js`, loaded with `<script src="kodza/kodza-topographic-lines.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-topographic-lines.css' -e 'd' -e '}' -e '/^KODZA-JS$/{r kodza/kodza-topographic-lines.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-topographic-lines', $uri . 'kodza-topographic-lines.css', [], filemtime($dir . 'kodza-topographic-lines.css'));
    wp_enqueue_script('kodza-topographic-lines', $uri . 'kodza-topographic-lines.js', [], filemtime($dir . 'kodza-topographic-lines.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: it takes the colour of the text around it (or keeps its own monochrome): nothing to point at the theme.
- 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 lines thin out under every line of it); set --kz-topographic-lines-bg and --kz-topographic-lines-ink to your theme's colours, and --kz-topographic-lines-speed to 0 for a still map.

#### 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-topographic-lines.css` and `assets/kodza-topographic-lines.js` (the Files commands with `-o assets/…`).
- In the section: `{{ 'kodza-topographic-lines.css' | asset_url | stylesheet_tag }}` and `<script src="{{ 'kodza-topographic-lines.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/`): `TopographicLines.tsx` (below), the CSS block under Code saved as `topographic-lines.css` and the JS block as `topographic-lines.js`, both as they are. The HTML block is already inside the component: skip it.
- Use it: `import { TopographicLines } from '@/components/kodza/TopographicLines';` then `<TopographicLines />`. Props: `vars` (the `--kz-…` variables listed under API, e.g. `vars={{ '--kz-topographic-lines-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 './topographic-lines.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.

```tsx
'use client';
// Kodza — Topographic Lines, for React. From https://kodza.dev/components/topographic-lines/
// 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: topographic-lines.css and topographic-lines.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.
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './topographic-lines.css';

export type TopographicLinesProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars"> & {
  /** Settings, as CSS variables on the root */
  vars?: {
    /** the paper — default: #f2f2f0 */
    "--kz-topographic-lines-bg"?: string | number;
    /** the lines, the text, the button — default: #0a0a0a */
    "--kz-topographic-lines-ink"?: string | number;
    /** default: rgba(10, 10, 10, 0.62) */
    "--kz-topographic-lines-muted"?: string | number;
    /** opacity of a contour line, 0 to 1 — default: 0.16 */
    "--kz-topographic-lines-minor"?: string | number;
    /** every fifth line, the index contour, a little thicker — default: 0.34 */
    "--kz-topographic-lines-major"?: string | number;
    /** how many lines a hill gets: more for steeper ground — default: 12 */
    "--kz-topographic-lines-levels"?: string | number;
    /** the size of the hills — default: 520px */
    "--kz-topographic-lines-scale"?: string | number;
    /** how fast the ground drifts (0: a still map) — default: 1 */
    "--kz-topographic-lines-speed"?: string | number;
    /** how high the pointer raises the ground (0: not at all) — default: 0.5 */
    "--kz-topographic-lines-lift"?: string | number;
    /** the radius of that hill — default: 140px */
    "--kz-topographic-lines-reach"?: string | number;
    /** how much of a line shows under the text, 0 to 1 — default: 0.2 */
    "--kz-topographic-lines-calm"?: string | number;
    /** default: 600px */
    "--kz-topographic-lines-height"?: string | number;
    /** the text column — default: 1152px */
    "--kz-topographic-lines-width"?: string | number;
    /** default: clamp(20px, 5vw, 64px) */
    "--kz-topographic-lines-side"?: string | number;
  };
  className?: string;
};

export function TopographicLines({ vars, className, style, ...rest }: TopographicLinesProps) {
  const ref = useRef<ComponentRef<'section'>>(null);
  const classes = ["kodza-topographic-lines", 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('./topographic-lines.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-topographic-lines="" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
      <div className="kodza-topographic-lines__content">
        <p className="kodza-topographic-lines__note">49.71° N · 0.21° E · contours every 10 m</p>
        <p className="kodza-topographic-lines__eyebrow">Côte d'Albâtre · on foot · 3 days</p>
        <div className="kodza-topographic-lines__grid">
          <h1 className="kodza-topographic-lines__title">
            <span className="kodza-topographic-lines__line">No signal.</span>
            {' '}
            <span className="kodza-topographic-lines__line">Good.</span>
          </h1>
          <div className="kodza-topographic-lines__side">
            <p className="kodza-topographic-lines__lead">Three days on the chalk cliffs above Étretat. Two nights in huts, one paper map, and a phone that stays in the bag the whole way.</p>
            <p className="kodza-topographic-lines__actions">
              <a className="kodza-topographic-lines__button" href="#">Book a departure</a>
              {' '}
              <a className="kodza-topographic-lines__button kodza-topographic-lines__button--ghost" href="#">See the route</a>
            </p>
          </div>
        </div>
      </div>
    </section>
  );
}
```

## Code

```html
<section class="kodza-topographic-lines" data-kodza-topographic-lines>
  <div class="kodza-topographic-lines__content">
    <p class="kodza-topographic-lines__note">49.71° N · 0.21° E · contours every 10 m</p>
    <p class="kodza-topographic-lines__eyebrow">Côte d'Albâtre · on foot · 3 days</p>
    <div class="kodza-topographic-lines__grid">
      <h1 class="kodza-topographic-lines__title">
        <span class="kodza-topographic-lines__line">No signal.</span>
        <span class="kodza-topographic-lines__line">Good.</span>
      </h1>
      <div class="kodza-topographic-lines__side">
        <p class="kodza-topographic-lines__lead">Three days on the chalk cliffs above Étretat. Two nights in huts, one paper map, and a phone that stays in the bag the whole way.</p>
        <p class="kodza-topographic-lines__actions">
          <a class="kodza-topographic-lines__button" href="#">Book a departure</a>
          <a class="kodza-topographic-lines__button kodza-topographic-lines__button--ghost" href="#">See the route</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 — Topographic Lines
   A hero over a contour map: the lines drift very slowly, every fifth one is an index contour,
   the pointer raises a hill under it, and the lines thin out under the text. One WebGL canvas,
   paused whenever nobody can see it. Tuned with the --kz-topographic-lines-* variables. */
.kodza-topographic-lines {
  --kz-topographic-lines-bg: #f2f2f0;             /* the paper */
  --kz-topographic-lines-ink: #0a0a0a;            /* the lines, the text, the button */
  --kz-topographic-lines-muted: rgba(10, 10, 10, 0.62);
  --kz-topographic-lines-minor: 0.16;             /* opacity of a contour line, 0 to 1 */
  --kz-topographic-lines-major: 0.34;             /* every fifth line, the index contour, a little thicker */
  --kz-topographic-lines-levels: 12;              /* how many lines a hill gets: more for steeper ground */
  --kz-topographic-lines-scale: 520px;            /* the size of the hills */
  --kz-topographic-lines-speed: 1;                /* how fast the ground drifts (0: a still map) */
  --kz-topographic-lines-lift: 0.5;               /* how high the pointer raises the ground (0: not at all) */
  --kz-topographic-lines-reach: 140px;            /* the radius of that hill */
  --kz-topographic-lines-calm: 0.2;               /* how much of a line shows under the text, 0 to 1 */
  --kz-topographic-lines-height: 600px;
  --kz-topographic-lines-width: 1152px;           /* the text column */
  --kz-topographic-lines-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-topographic-lines-height);
  padding: 120px var(--kz-topographic-lines-side) 72px;
  overflow: hidden;
  background: var(--kz-topographic-lines-bg);
  color: var(--kz-topographic-lines-ink);
}

/* The map: drawn by the script on a canvas, and as plain CSS rings around one summit until then
   (or without WebGL), still */
.kodza-topographic-lines::before,
.kodza-topographic-lines__canvas {
  position: absolute;
  inset: 0;
  z-index: -1;
}

.kodza-topographic-lines::before {
  content: "";
  --kz-topographic-lines-ring: 28px;
  background-image: repeating-radial-gradient(ellipse 100% 78% at 80% 20%, transparent 0 calc(var(--kz-topographic-lines-ring) - 1px), color-mix(in srgb, var(--kz-topographic-lines-ink) calc(var(--kz-topographic-lines-minor) * 100%), transparent) calc(var(--kz-topographic-lines-ring) - 1px) var(--kz-topographic-lines-ring));
  -webkit-mask-image: radial-gradient(ellipse 75% 95% at 80% 20%, #000 35%, transparent 80%);
  mask-image: radial-gradient(ellipse 75% 95% at 80% 20%, #000 35%, transparent 80%);
}

/* The rings give way while the map surfaces, not in one cut */
.kodza-topographic-lines.is-live::before {
  opacity: 0;
  transition: opacity 0.9s cubic-bezier(0.65, 0, 0.35, 1);
}

.kodza-topographic-lines__canvas {
  display: block;
  width: 100%;
  height: 100%;
  color: var(--kz-topographic-lines-ink); /* the script reads the line colour here */
  pointer-events: none;
}

.kodza-topographic-lines__content {
  width: 100%;
  max-width: var(--kz-topographic-lines-width);
  margin: 0 auto;
}

/* The map's legend in the corner: the lines clear around it too */
.kodza-topographic-lines__note {
  position: absolute;
  top: 40px;
  right: max(var(--kz-topographic-lines-side), calc((100% - var(--kz-topographic-lines-width)) / 2));
  margin: 0;
  font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12px;
  line-height: 1.4;
}

.kodza-topographic-lines__eyebrow {
  margin: 0 0 20px;
  color: var(--kz-topographic-lines-muted);
  font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12px;
  line-height: 1.4;
}

.kodza-topographic-lines__grid {
  display: grid;
  grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
  align-items: end;
  gap: 28px 64px;
}

.kodza-topographic-lines__title {
  margin: 0;
  font-size: clamp(44px, 6.6vw, 92px);
  font-weight: 600;
  line-height: 0.94;
  letter-spacing: -0.055em;
}

.kodza-topographic-lines__line {
  display: block;
}

.kodza-topographic-lines__lead {
  max-width: 30em;
  margin: 0 0 24px;
  color: var(--kz-topographic-lines-muted);
  font-size: clamp(16px, 1.4vw, 18px);
  line-height: 1.5;
  text-wrap: pretty;
}

.kodza-topographic-lines__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin: 0;
}

.kodza-topographic-lines__button {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  height: 48px;
  padding: 0 22px;
  border-radius: 999px;
  background: var(--kz-topographic-lines-ink);
  color: var(--kz-topographic-lines-bg);
  font-size: 15px;
  font-weight: 500;
  letter-spacing: -0.01em;
  text-decoration: none;
  white-space: nowrap;
  transition: opacity 0.18s ease;
}

.kodza-topographic-lines__button:hover {
  opacity: 0.86;
}

.kodza-topographic-lines__button--ghost {
  background: var(--kz-topographic-lines-bg);
  color: inherit;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 22%, transparent);
}

.kodza-topographic-lines__button:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 3px;
}

@media (max-width: 720px) {
  .kodza-topographic-lines {
    padding: 96px var(--kz-topographic-lines-side) 56px;
  }

  .kodza-topographic-lines__note {
    top: 28px;
  }

  .kodza-topographic-lines__grid {
    grid-template-columns: minmax(0, 1fr);
    gap: 24px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .kodza-topographic-lines.is-live::before,
  .kodza-topographic-lines__button {
    transition: none;
  }
}

/* kodza-guard 09d3f5eb48ff — 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-topographic-lines__button):not(#_) {
  outline: revert;
  box-shadow: revert;
  outline-offset: revert;
}

/* What the browser gives, on everything the component leaves to it */

.kodza-topographic-lines__canvas:not(#_),
.kodza-topographic-lines__content:not(#_),
.kodza-topographic-lines__grid:not(#_),
.kodza-topographic-lines__line:not(#_),
.kodza-topographic-lines__side:not(#_) {
  box-sizing: revert;
}

.kodza-topographic-lines__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-topographic-lines__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-topographic-lines__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-topographic-lines__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-topographic-lines__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-topographic-lines__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-topographic-lines__note:not(#_):where(:not([hidden])),
.kodza-topographic-lines__eyebrow:not(#_):where(:not([hidden])),
.kodza-topographic-lines__title:not(#_):where(:not([hidden])),
.kodza-topographic-lines__lead:not(#_):where(:not([hidden])) {
  display: revert;
}

.kodza-topographic-lines__note:not(#_):where([hidden]),
.kodza-topographic-lines__eyebrow:not(#_):where([hidden]),
.kodza-topographic-lines__title:not(#_):where([hidden]),
.kodza-topographic-lines__lead:not(#_):where([hidden]) {
  display: none;
}

.kodza-topographic-lines:not(#_)::before {
  box-sizing: revert;
}

/* 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-topographic-lines__note:not(#_) {
  position: absolute;
  margin: 0;
  font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12px;
  line-height: 1.4;
}

.kodza-topographic-lines__eyebrow:not(#_) {
  margin: 0 0 20px;
  color: var(--kz-topographic-lines-muted);
  font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12px;
  line-height: 1.4;
}

.kodza-topographic-lines__title:not(#_) {
  margin: 0;
  font-size: clamp(44px, 6.6vw, 92px);
  font-weight: 600;
  line-height: 0.94;
  letter-spacing: -0.055em;
}

.kodza-topographic-lines__lead:not(#_) {
  max-width: 30em;
  margin: 0 0 24px;
  color: var(--kz-topographic-lines-muted);
  font-size: clamp(16px, 1.4vw, 18px);
  line-height: 1.5;
}

.kodza-topographic-lines__actions:not(#_) {
  display: flex;
  margin: 0;
}

.kodza-topographic-lines__button:not(#_) {
  box-sizing: border-box;
  display: inline-flex;
  padding: 0 22px;
  border-radius: 999px;
  background: var(--kz-topographic-lines-ink);
  color: var(--kz-topographic-lines-bg);
  font-size: 15px;
  font-weight: 500;
  letter-spacing: -0.01em;
  text-decoration: none;
  transition: opacity 0.18s ease;
}

.kodza-topographic-lines__button--ghost:not(#_) {
  background: var(--kz-topographic-lines-bg);
  color: inherit;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 22%, transparent);
}

.kodza-topographic-lines__button:focus-visible:not(#_) {
  outline: 2px solid currentColor;
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .kodza-topographic-lines__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 — Topographic Lines
// Draws a contour map behind the hero with a native WebGL shader: height lines cut from a slow
// noise that drifts a few pixels a second, every fifth one an index contour, a hill that rises
// under the pointer, and lines that thin out under the text. The first time it is seen, the map
// surfaces from its summits down. Without WebGL, plain CSS rings stay, still.
(() => {
  const SELECTOR = '[data-kodza-topographic-lines]';
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // ---- Kodza shader engine 1.0 -----------------------------------------------------------
  // 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.
  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('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 (!gl.getExtension('OES_standard_derivatives')) return false; // fwidth keeps every line one pixel wide
      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, 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 true;
    };
    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 CSS rings
        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 map: a height field of slow noise, cut into contour lines by the shader. Every fifth line
  // is an index contour, a little darker and thicker; lines packed too tight to read fade out
  // instead of turning into a grey smear. The hand raises a hill that follows it and sinks back
  // once it leaves. The first time it is seen, the map surfaces from its summits down (the sea
  // level drops), holds still for a second, and only then starts to drift.
  const ROLL = 1.1;      // seconds for the map to surface
  const SPREAD = 0.088;  // how widely the heights of the ground spread around mid height
  const REST = 1;        // seconds it holds still once surfaced
  const DRIFT_IN = 4;    // seconds for the drift to come in after that
  const RISE = 2.4;      // how fast the hill rises under the hand (per second)
  const SINK = 1.1;      // and sinks back once the hand leaves
  const FOLLOW = 4;      // how closely the hill follows the hand
  const smooth = (t) => {
    const k = Math.min(Math.max(t, 0), 1);
    return k * k * (3 - 2 * k);
  };

  const topo = {
    canvas: 'kodza-topographic-lines__canvas',
    quiet: '.kodza-topographic-lines__content',
    calm: '--kz-topographic-lines-calm',
    idleFps: 30,
    uniforms: ['u_minor', 'u_major', 'u_levels', 'u_scale', 'u_drift', 'u_hill', 'u_reach', 'u_sea'],
    fragment: `
#extension GL_OES_standard_derivatives : enable
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
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 float u_minor;
uniform float u_major;
uniform float u_levels;
uniform float u_scale;
uniform float u_drift;
uniform vec3 u_hill;
uniform float u_reach;
uniform float u_sea;

vec2 grad(vec2 i) {
  float a = 6.2831853 * fract(sin(dot(i, vec2(127.1, 311.7))) * 43758.5453);
  return vec2(cos(a), sin(a));
}

// Gradient noise, smooth to the second derivative: contour lines without kinks
float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 e = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
  return mix(
    mix(dot(grad(i), f), dot(grad(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), e.x),
    mix(dot(grad(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)), dot(grad(i + 1.0), f - 1.0), e.x),
    e.y);
}

// Three octaves, each turned and drifting its own way: the ground changes shape as it moves
float ground(vec2 p, float t) {
  mat2 turn = mat2(0.8, 0.6, -0.6, 0.8);
  float h = 0.62 * noise(p + t * vec2(0.009, 0.004));
  p = turn * p * 2.03;
  h += 0.27 * noise(p + t * vec2(-0.012, 0.0155));
  p = turn * p * 2.01;
  h += 0.11 * noise(p + t * vec2(-0.015, 0.025));
  return h;
}

void main() {
  vec2 css = vec2(gl_FragCoord.x, u_size.y * u_ratio - gl_FragCoord.y) / u_ratio;
  vec2 d = css - u_hill.xy;
  float h = ground(css / u_scale + vec2(3.7, 1.9), u_drift) + u_hill.z * exp(-dot(d, d) / (2.0 * u_reach * u_reach));
  float v = h * u_levels;
  float w = max(fwidth(v), 1e-4);
  float n = floor(v + 0.5);
  float index = step(mod(n + 0.5, 5.0), 1.0);
  float hw = mix(0.5, 0.8, index) * u_ratio;
  float a = 1.0 - smoothstep(hw - 0.5, hw + 0.5, abs(v - n) / w);
  a *= mix(u_minor, u_major, index);
  a *= smoothstep(2.0, 5.0, 1.0 / (w * u_ratio));
  a *= smoothstep(u_sea, u_sea + 0.12, h);
  a *= mix(u_calm, 1.0, texture2D(u_quiet, css / u_quietSize).r);
  a *= u_ink.a;
  gl_FragColor = vec4(u_ink.rgb * a, a);
}`,
    setup(s) {
      const css = getComputedStyle(s.root);
      const read = (name, fallback, min, max) => {
        const value = parseFloat(css.getPropertyValue(`--kz-topographic-lines-${name}`));
        return Math.min(Math.max(Number.isFinite(value) ? value : fallback, min), max);
      };
      s.minor = read('minor', 0.16, 0, 1);
      s.major = read('major', 0.34, 0, 1);
      s.levels = read('levels', 12, 2, 40);
      s.mapScale = read('scale', 520, 80, 4000);
      s.speed = read('speed', 1, 0, 10);
      s.lift = read('lift', 0.5, 0, 2);
      s.reach = read('reach', 140, 20, 1000);
      s.drift ??= 0;
      s.hill ??= { x: 0, y: 0, h: 0 };
    },
    enter(s) {
      s.born = s.time;
    },
    step(s, dt) {
      if (s.born === undefined) return 0;
      const since = s.time - s.born;
      // Exactly still until the rest is over: the map always holds the same picture first
      s.drift += dt * s.speed * smooth((since - ROLL - REST) / DRIFT_IN);
      const { hill, pointer: p } = s;
      const target = p.active ? 1 : 0;
      if (p.active && hill.h < 0.02) { // a new hill rises where the hand is, it does not slide there
        hill.x = p.x;
        hill.y = p.y;
      }
      hill.h += (target - hill.h) * (1 - Math.exp(-dt * (target > hill.h ? RISE : SINK)));
      if (!p.active && hill.h < 0.003) hill.h = 0;
      const away = Math.hypot(p.x - hill.x, p.y - hill.y);
      if (p.active) {
        const k = 1 - Math.exp(-dt * FOLLOW);
        hill.x += (p.x - hill.x) * k;
        hill.y += (p.y - hill.y) * k;
      }
      if (since < ROLL || Math.abs(target - hill.h) > 0.003 || (p.active && away > 0.5)) return 2;
      return s.speed > 0 ? 1 : 0;
    },
    draw(s, gl, u) {
      const since = s.born === undefined ? (s.still ? Infinity : 0) : s.time - s.born;
      const roll = Math.min(since / ROLL, 1);
      // The sea level: above every summit before the entrance, below every valley after it. It goes
      // down by equal shares of ground, not of height (most of the ground is near mid height): the
      // summits come out first, then the slopes, then the valleys, over the whole roll
      const shown = Math.min(Math.max(smooth(roll), 0.002), 0.998);
      const sea = roll < 1 ? SPREAD * Math.log((1 - shown) / shown) - 0.06 : -10;
      gl.uniform1f(u.u_minor, s.minor);
      gl.uniform1f(u.u_major, s.major);
      gl.uniform1f(u.u_levels, s.levels);
      gl.uniform1f(u.u_scale, s.mapScale);
      gl.uniform1f(u.u_drift, s.drift);
      gl.uniform3f(u.u_hill, s.hill.x, s.hill.y, s.hill.h * s.lift);
      gl.uniform1f(u.u_reach, s.reach);
      gl.uniform1f(u.u_sea, sea);
    },
  };

  // 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, topo);
  }

  // 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).
