# Interactive Dots

> A hero over a field of fine dots. The pointer pushes them aside like a hand in sand, they spring back, and a click sends a wave across. One canvas, asleep when nobody is looking.

Backgrounds & textures · background, canvas, hero, dots, pointer, interactive · HTML, CSS, vanilla JS · no dependencies · updated 2026-10-20
Demo: https://kodza.dev/components/interactive-dots/

## Files

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

- [`kodza-interactive-dots.css`](https://kodza.dev/registry/interactive-dots/kodza-interactive-dots.css) · 10.9 KB
- [`kodza-interactive-dots.js`](https://kodza.dev/registry/interactive-dots/kodza-interactive-dots.js) · 18.1 KB
- [`kodza-interactive-dots.html`](https://kodza.dev/registry/interactive-dots/kodza-interactive-dots.html) · 1.0 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-interactive-dots.css https://kodza.dev/registry/interactive-dots/kodza-interactive-dots.css
curl -fsSL --create-dirs -o kodza/kodza-interactive-dots.js https://kodza.dev/registry/interactive-dots/kodza-interactive-dots.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-interactive-dots]`
- Variables (set them on the root element itself: it declares its own defaults):
  - `--kz-interactive-dots-bg: #f2f2f0` — the paper
  - `--kz-interactive-dots-ink: #0a0a0a` — the dots, the text, the button
  - `--kz-interactive-dots-muted: rgba(10, 10, 10, 0.62)`
  - `--kz-interactive-dots-gap: 22px` — from one dot to the next
  - `--kz-interactive-dots-size: 1.2px` — dot radius at rest
  - `--kz-interactive-dots-rest: 0.24` — dot opacity at rest
  - `--kz-interactive-dots-lift: 0.7` — how much darker a pushed dot gets, 0 to 1
  - `--kz-interactive-dots-grow: 0` — how much bigger it gets (0.5 = half again)
  - `--kz-interactive-dots-reach: 104px` — radius of the hand
  - `--kz-interactive-dots-calm: 0.3` — how much a pushed dot lights up under the text, 0 to 1
  - `--kz-interactive-dots-height: 600px`
  - `--kz-interactive-dots-width: 1152px` — the text column
  - `--kz-interactive-dots-side: clamp(20px, 5vw, 64px)`
- Attributes:
  - `data-kodza-interactive-dots`
- 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-interactive-dots {
    --kz-interactive-dots-bg: #0a0a0a;
    --kz-interactive-dots-ink: #f2f2f0;
    --kz-interactive-dots-muted: rgba(242, 242, 240, 0.62);
    --kz-interactive-dots-rest: 0.26;
  }
}
```

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

## Use it

### Plain HTML page

- CSS: `kodza/kodza-interactive-dots.css` next to the page (the Files commands as they are), linked in `<head>`: `<link rel="stylesheet" href="kodza/kodza-interactive-dots.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-interactive-dots.js`, loaded with `<script src="kodza/kodza-interactive-dots.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-interactive-dots.css' -e 'd' -e '}' -e '/^KODZA-JS$/{r kodza/kodza-interactive-dots.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-interactive-dots', $uri . 'kodza-interactive-dots.css', [], filemtime($dir . 'kodza-interactive-dots.css'));
    wp_enqueue_script('kodza-interactive-dots', $uri . 'kodza-interactive-dots.js', [], filemtime($dir . 'kodza-interactive-dots.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; set --kz-interactive-dots-bg and --kz-interactive-dots-ink to your theme's colours.

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

export type InteractiveDotsProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars"> & {
  /** Settings, as CSS variables on the root */
  vars?: {
    /** the paper — default: #f2f2f0 */
    "--kz-interactive-dots-bg"?: string | number;
    /** the dots, the text, the button — default: #0a0a0a */
    "--kz-interactive-dots-ink"?: string | number;
    /** default: rgba(10, 10, 10, 0.62) */
    "--kz-interactive-dots-muted"?: string | number;
    /** from one dot to the next — default: 22px */
    "--kz-interactive-dots-gap"?: string | number;
    /** dot radius at rest — default: 1.2px */
    "--kz-interactive-dots-size"?: string | number;
    /** dot opacity at rest — default: 0.24 */
    "--kz-interactive-dots-rest"?: string | number;
    /** how much darker a pushed dot gets, 0 to 1 — default: 0.7 */
    "--kz-interactive-dots-lift"?: string | number;
    /** how much bigger it gets (0.5 = half again) — default: 0 */
    "--kz-interactive-dots-grow"?: string | number;
    /** radius of the hand — default: 104px */
    "--kz-interactive-dots-reach"?: string | number;
    /** how much a pushed dot lights up under the text, 0 to 1 — default: 0.3 */
    "--kz-interactive-dots-calm"?: string | number;
    /** default: 600px */
    "--kz-interactive-dots-height"?: string | number;
    /** the text column — default: 1152px */
    "--kz-interactive-dots-width"?: string | number;
    /** default: clamp(20px, 5vw, 64px) */
    "--kz-interactive-dots-side"?: string | number;
  };
  className?: string;
};

export function InteractiveDots({ vars, className, style, ...rest }: InteractiveDotsProps) {
  const ref = useRef<ComponentRef<'section'>>(null);
  const classes = ["kodza-interactive-dots", 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('./interactive-dots.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-interactive-dots="" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
      <div className="kodza-interactive-dots__content">
        <p className="kodza-interactive-dots__eyebrow">One canvas · zero dependencies</p>
        <div className="kodza-interactive-dots__grid">
          <h1 className="kodza-interactive-dots__title">
            <span className="kodza-interactive-dots__line">Poke it.</span>
            {' '}
            <span className="kodza-interactive-dots__line">It pokes back.</span>
          </h1>
          <div className="kodza-interactive-dots__side">
            <p className="kodza-interactive-dots__lead">Every dot gets out of your cursor's way, then springs right back. Click anywhere to send a wave. The headline stays readable, we checked.</p>
            <p className="kodza-interactive-dots__actions">
              <a className="kodza-interactive-dots__button" href="#">Start shipping</a>
              {' '}
              <a className="kodza-interactive-dots__button kodza-interactive-dots__button--ghost" href="#">Read the docs</a>
            </p>
          </div>
        </div>
      </div>
    </section>
  );
}
```

## Code

```html
<section class="kodza-interactive-dots" data-kodza-interactive-dots>
  <div class="kodza-interactive-dots__content">
    <p class="kodza-interactive-dots__eyebrow">One canvas · zero dependencies</p>
    <div class="kodza-interactive-dots__grid">
      <h1 class="kodza-interactive-dots__title">
        <span class="kodza-interactive-dots__line">Poke it.</span>
        <span class="kodza-interactive-dots__line">It pokes back.</span>
      </h1>
      <div class="kodza-interactive-dots__side">
        <p class="kodza-interactive-dots__lead">Every dot gets out of your cursor's way, then springs right back. Click anywhere to send a wave. The headline stays readable, we checked.</p>
        <p class="kodza-interactive-dots__actions">
          <a class="kodza-interactive-dots__button" href="#">Start shipping</a>
          <a class="kodza-interactive-dots__button kodza-interactive-dots__button--ghost" href="#">Read the docs</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 — Interactive Dots
   A hero over a field of fine dots: the pointer pushes them aside like a hand in sand, springs
   bring them home, and a click sends a wave across. One canvas, asleep whenever nothing moves
   or nobody can see it. Everything is tuned with the --kz-interactive-dots-* variables. */
.kodza-interactive-dots {
  --kz-interactive-dots-bg: #f2f2f0;            /* the paper */
  --kz-interactive-dots-ink: #0a0a0a;           /* the dots, the text, the button */
  --kz-interactive-dots-muted: rgba(10, 10, 10, 0.62);
  --kz-interactive-dots-gap: 22px;              /* from one dot to the next */
  --kz-interactive-dots-size: 1.2px;            /* dot radius at rest */
  --kz-interactive-dots-rest: 0.24;             /* dot opacity at rest */
  --kz-interactive-dots-lift: 0.7;              /* how much darker a pushed dot gets, 0 to 1 */
  --kz-interactive-dots-grow: 0;                /* how much bigger it gets (0.5 = half again) */
  --kz-interactive-dots-reach: 104px;           /* radius of the hand */
  --kz-interactive-dots-calm: 0.3;              /* how much a pushed dot lights up under the text, 0 to 1 */
  --kz-interactive-dots-height: 600px;
  --kz-interactive-dots-width: 1152px;          /* the text column */
  --kz-interactive-dots-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-interactive-dots-height);
  padding: 120px var(--kz-interactive-dots-side) 72px;
  overflow: hidden;
  background: var(--kz-interactive-dots-bg);
  color: var(--kz-interactive-dots-ink);
}

/* The field: drawn by the script on a canvas, and as plain CSS dots until then (or without it).
   Both fade in from the top, and run on behind the text. */
.kodza-interactive-dots::before,
.kodza-interactive-dots__canvas {
  position: absolute;
  inset: 0;
  z-index: -1;
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 16%);
  mask-image: linear-gradient(to bottom, transparent, #000 16%);
}

.kodza-interactive-dots::before {
  content: "";
  background-image: radial-gradient(circle, color-mix(in srgb, var(--kz-interactive-dots-ink) calc(var(--kz-interactive-dots-rest) * 100%), transparent) var(--kz-interactive-dots-size), transparent calc(var(--kz-interactive-dots-size) + 0.6px));
  background-position: center;
  background-size: var(--kz-interactive-dots-gap) var(--kz-interactive-dots-gap);
}

.kodza-interactive-dots.is-live::before {
  content: none;
}

.kodza-interactive-dots__canvas {
  display: block;
  width: 100%;
  height: 100%;
  color: var(--kz-interactive-dots-ink); /* the script reads the dot colour here */
  pointer-events: none;
}

.kodza-interactive-dots__content {
  width: 100%;
  max-width: var(--kz-interactive-dots-width);
  margin: 0 auto;
}

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

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

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

.kodza-interactive-dots__line {
  display: block;
}

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

.kodza-interactive-dots__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin: 0;
}

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

.kodza-interactive-dots__button:hover {
  opacity: 0.86;
}

/* Opaque, so it sits on the dots like a pebble instead of letting them through */
.kodza-interactive-dots__button--ghost {
  background: var(--kz-interactive-dots-bg);
  color: inherit;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 22%, transparent);
}

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

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

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

@media (prefers-reduced-motion: reduce) {
  .kodza-interactive-dots__button {
    transition: none;
  }
}

/* kodza-guard 178d89a341fd — 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-interactive-dots__button):not(#_) {
  outline: revert;
  box-shadow: revert;
  outline-offset: revert;
}

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

.kodza-interactive-dots__canvas:not(#_),
.kodza-interactive-dots__content:not(#_),
.kodza-interactive-dots__grid:not(#_),
.kodza-interactive-dots__line:not(#_),
.kodza-interactive-dots__side:not(#_) {
  box-sizing: revert;
}

.kodza-interactive-dots__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-interactive-dots__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-interactive-dots__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-interactive-dots__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-interactive-dots__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-interactive-dots__eyebrow:not(#_):where(:not([hidden])),
.kodza-interactive-dots__title:not(#_):where(:not([hidden])),
.kodza-interactive-dots__lead:not(#_):where(:not([hidden])) {
  display: revert;
}

.kodza-interactive-dots__eyebrow:not(#_):where([hidden]),
.kodza-interactive-dots__title:not(#_):where([hidden]),
.kodza-interactive-dots__lead:not(#_):where([hidden]) {
  display: none;
}

.kodza-interactive-dots: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-interactive-dots__eyebrow:not(#_) {
  margin: 0 0 20px;
  color: var(--kz-interactive-dots-muted);
  font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12px;
  line-height: 1.4;
}

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

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

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

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

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

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

@media (prefers-reduced-motion: reduce) {
  .kodza-interactive-dots__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 — Interactive Dots
// Draws the field of dots on a canvas behind the hero: the pointer pushes them aside, springs
// bring them home, a click sends a wave. Without the script, the CSS dots stay, still.
(() => {
  const SELECTOR = '[data-kodza-interactive-dots]';
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // ---- Kodza field engine 1.1 ------------------------------------------------------------
  // The same copy in every canvas background of the family (Halftone Fade, Plus Pattern, Square
  // Grid Hover…): it owns the canvas, a grid of points on springs, the pointer, the pulses and the
  // pauses (off-screen, hidden tab, nothing moving). A background hands it a layer that says how
  // the points are pushed and drawn. Change it here, then copy it to the others as is.
  //   layer = { canvas: 'kodza-…__canvas', gap: '--kz-…-gap', spring: [stiffness, damping],
  //             quiet: '<the text over the field>', calm: '--kz-…-calm',
  //             pulseLife: seconds, ambient: true to never sleep while seen,
  //             setup(f), enter(f), push(f, dt), draw(f, ctx) }
  const MAX_POINTS = 5000;

  function field(root, signal, layer) {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    if (!ctx) return null; // the still field drawn in CSS stays
    canvas.className = layer.canvas;
    canvas.setAttribute('aria-hidden', 'true');
    root.prepend(canvas);
    root.classList.add('is-live');

    const [stiffness, damping] = layer.spring || [46, 8.5];
    const f = {
      root, canvas, ctx, still: reduceMotion, time: 0, scale: 1,
      width: 0, height: 0, gap: 0, cols: 0, rows: 0, count: 0, x0: 0, y0: 0, ink: '', bg: '',
      pointer: { x: 0, y: 0, vx: 0, vy: 0, active: false, t: 0 },
      pulses: [],
      pulse(x, y, power = 1) {
        if (f.still) return;
        f.pulses.push({ x, y, power, t: f.time });
        if (f.pulses.length > 6) f.pulses.shift();
        wake();
      },
      wake: () => wake(),
    };
    let raf = 0;
    let last = 0;
    let onScreen = false;
    let shown = !document.hidden;
    let entered = false;
    let size = '';

    const colors = () => {
      f.ink = getComputedStyle(canvas).color;
      f.bg = getComputedStyle(root).backgroundColor;
    };
    // 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 field calms down (f.quiet: 1 in the open, `calm` under a line of text),
    // so the headline stays readable at the busiest moment. Feathered: never a visible box
    const quietZones = () => {
      f.quiet = new Float32Array(f.count).fill(1);
      const calm = parseFloat(getComputedStyle(root).getPropertyValue(layer.calm));
      f.calm = Number.isFinite(calm) ? Math.min(Math.max(calm, 0), 1) : 1;
      if (!layer.quiet || f.calm === 1) return;
      const box = root.getBoundingClientRect();
      const s = 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) / s, (r.top - box.top) / s, (r.right - box.left) / s, (r.bottom - box.top) / s]);
          }
        }
      });
      const feather = f.gap * 1.5;
      for (let i = 0; i < f.count; i++) {
        let near = Infinity;
        for (const [l, t, r, b] of lines) {
          near = Math.min(near, Math.hypot(Math.max(l - f.hx[i], 0, f.hx[i] - r), Math.max(t - f.hy[i], 0, f.hy[i] - b)));
        }
        const k = Math.min(Math.max(near - f.gap / 2, 0) / feather, 1);
        f.quiet[i] = f.calm + (1 - f.calm) * k * k * (3 - 2 * k);
      }
    };

    // The grid is centred like the CSS dots (one on the centre), with a spare row past each edge
    const build = () => {
      const w = root.offsetWidth;
      const h = root.offsetHeight;
      if (!w || !h) return false;
      size = `${w}x${h}`;
      f.scale = scaleOf();
      const ratio = Math.min((window.devicePixelRatio || 1) * f.scale, 3);
      canvas.width = Math.round(w * ratio);
      canvas.height = Math.round(h * ratio);
      ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
      const wanted = parseFloat(getComputedStyle(root).getPropertyValue(layer.gap)) || 24;
      const gap = Math.max(wanted, Math.sqrt((w * h) / MAX_POINTS)); // a huge section gets fewer dots, not a slow one
      const x0 = ((w / 2) % gap) - gap;
      const y0 = ((h / 2) % gap) - gap;
      const cols = Math.ceil((w - x0) / gap) + 1;
      const rows = Math.ceil((h - y0) / gap) + 1;
      const count = cols * rows;
      Object.assign(f, { width: w, height: h, gap, cols, rows, count, x0, y0 });
      ['hx', 'hy', 'ox', 'oy', 'vx', 'vy', 'fx', 'fy'].forEach((key) => { f[key] = new Float32Array(count); });
      for (let r = 0, i = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++, i++) {
          f.hx[i] = x0 + c * gap;
          f.hy[i] = y0 + r * gap;
        }
      }
      colors();
      quietZones();
      layer.setup?.(f);
      return true;
    };

    const paint = () => {
      ctx.clearRect(0, 0, f.width, f.height);
      layer.draw(f, ctx);
    };

    const running = () => onScreen && shown && !f.still;

    function wake() {
      if (raf || !running()) return;
      if (Math.abs(scaleOf() - f.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;
      f.time += dt;
      const p = f.pointer;
      const slow = Math.exp(-dt * 8); // a hand that stops stops pushing forward
      p.vx *= slow;
      p.vy *= slow;
      f.fx.fill(0);
      f.fy.fill(0);
      layer.push(f, dt);

      // Springs back home, integrated semi-implicitly (stable at 30 fps too)
      const { ox, oy, vx, vy, fx, fy, count } = f;
      let moving = 0;
      let away = 0;
      for (let i = 0; i < count; i++) {
        vx[i] += (fx[i] - stiffness * ox[i] - damping * vx[i]) * dt;
        vy[i] += (fy[i] - stiffness * oy[i] - damping * vy[i]) * dt;
        ox[i] += vx[i] * dt;
        oy[i] += vy[i] * dt;
        moving = Math.max(moving, Math.abs(vx[i]) + Math.abs(vy[i]));
        away = Math.max(away, Math.abs(ox[i]) + Math.abs(oy[i]));
      }
      const life = layer.pulseLife || 2.5;
      f.pulses = f.pulses.filter((q) => f.time - q.t < life);

      // Asleep once it has all settled: no frame is drawn for nothing
      const settled = !layer.ambient && !f.pulses.length && moving < 1 && Math.abs(p.vx) + Math.abs(p.vy) < 4;
      if (settled && !p.active && away < 0.3) {
        ox.fill(0);
        oy.fill(0);
        vx.fill(0);
        vy.fill(0);
      }
      paint();
      if (!settled) raf = requestAnimationFrame(frame);
    }

    if (!f.still) {
      const local = (e) => {
        const box = root.getBoundingClientRect();
        const s = box.width / root.offsetWidth || 1;
        return [(e.clientX - box.left) / s, (e.clientY - box.top) / s];
      };
      root.addEventListener('pointermove', (e) => {
        const [x, y] = local(e);
        const p = f.pointer;
        const dt = (e.timeStamp - p.t) / 1000;
        if (p.active && dt > 0 && dt < 0.1) {
          const k = Math.max(dt, 1 / 240);
          p.vx = p.vx * 0.5 + ((x - p.x) / k) * 0.5;
          p.vy = p.vy * 0.5 + ((y - p.y) / k) * 0.5;
        }
        Object.assign(p, { x, y, t: e.timeStamp, active: true });
        wake();
      }, { signal });
      const leave = () => {
        f.pointer.active = false;
        wake();
      };
      root.addEventListener('pointerleave', leave, { signal });
      root.addEventListener('pointercancel', leave, { signal }); // the finger started a scroll
      root.addEventListener('pointerdown', (e) => {
        if (e.button > 0) return;
        f.pulse(...local(e));
      }, { signal });
      // A click from the keyboard sends its pulse from the control itself
      root.addEventListener('click', (e) => {
        if (e.detail !== 0 || !(e.target instanceof Element) || e.target === root) return;
        const box = e.target.getBoundingClientRect();
        f.pulse(...local({ clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 }));
      }, { signal });
    }

    const io = new IntersectionObserver((entries) => {
      onScreen = entries[entries.length - 1].isIntersecting;
      if (onScreen && !entered && !f.still) {
        entered = true;
        layer.enter?.(f);
      }
      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 });

    signal.addEventListener('abort', () => {
      cancelAnimationFrame(raf);
      io.disconnect();
      ro.disconnect();
      canvas.width = 0; // hands the pixels back now, not at the next garbage collection
      canvas.height = 0;
      canvas.remove();
    });

    if (build()) paint();
    // The text moves once the web font is in: calm the field under its final lines
    document.fonts?.ready.then(() => {
      if (signal.aborted || !f.count) return;
      quietZones();
      paint();
    });
    return f;
  }
  // ---- end of the field engine -----------------------------------------------------------

  // The dots: the hand presses a hollow and piles the dots in a ridge around it, a fast hand
  // plows them along, and each pulse is a ring that sweeps outward, shoving and inking the dots
  // it crosses. The ring is drawn directly, not through the springs: it stays a crisp circle.
  const PRESS = 7;         // how hard the hand pushes, against springs of strength 1
  const DRAG = 1.1;        // how much a moving hand carries the dots with it
  const SETTLE = 14;       // how fast the hand lands and lifts (1/s): the hollow opens in ~150 ms
  const WAVE = 9;          // how far the ring shoves the dots (px)
  const WAVE_SPEED = 560;  // px per second
  const WAVE_WIDTH = 84;   // px, each side of the crest
  const WAVE_LIFE = 2.6;   // seconds
  const STIFFNESS = 46;
  const SHADES = 6;        // dots are sorted into a few shades: thousands of arcs, a few fills
  const QUIET = 4;         // and a few steps of calm under the text

  const dots = {
    canvas: 'kodza-interactive-dots__canvas',
    gap: '--kz-interactive-dots-gap',
    spring: [STIFFNESS, 8.5],
    quiet: '.kodza-interactive-dots__eyebrow, .kodza-interactive-dots__title, .kodza-interactive-dots__lead',
    calm: '--kz-interactive-dots-calm',
    pulseLife: WAVE_LIFE,

    setup(f) {
      const css = getComputedStyle(f.root);
      const read = (name, fallback) => {
        const value = parseFloat(css.getPropertyValue(`--kz-interactive-dots-${name}`));
        return Number.isFinite(value) ? value : fallback;
      };
      f.size = read('size', 1.2);
      f.rest = Math.min(Math.max(read('rest', 0.24), 0), 1);
      f.lift = Math.min(Math.max(read('lift', 0.7), 0), 1);
      f.grow = Math.max(read('grow', 0), 0);
      f.reach = read('reach', 104);
      f.presence = f.presence || 0;
      f.shade = new Uint8Array(f.count);
      ['wx', 'wy', 'we', 'grain'].forEach((key) => { f[key] = new Float32Array(f.count); });
      // A fixed grain per dot, so the dots piled on the rim never stack exactly on one another
      for (let i = 0; i < f.count; i++) {
        const n = Math.sin(i * 12.9898 + 78.233) * 43758.5453;
        f.grain[i] = (n - Math.floor(n)) * 2 - 1;
      }
    },

    // The first time it is seen, one soft wave from the middle says it is alive
    enter(f) {
      f.pulse(f.width / 2, f.height * 0.4, 0.6);
    },

    push(f, dt) {
      const { hx, hy, ox, oy, fx, fy, cols, rows, gap, x0, y0, grain, pointer: p } = f;
      const k = STIFFNESS;
      f.presence += ((p.active ? 1 : 0) - f.presence) * (1 - Math.exp(-dt * SETTLE));
      if (f.presence > 0.002) {
        const R = f.reach;
        const span = R + gap * 2;
        const c0 = Math.max(0, Math.floor((p.x - span - x0) / gap));
        const c1 = Math.min(cols - 1, Math.ceil((p.x + span - x0) / gap));
        const r0 = Math.max(0, Math.floor((p.y - span - y0) / gap));
        const r1 = Math.min(rows - 1, Math.ceil((p.y + span - y0) / gap));
        for (let r = r0; r <= r1; r++) {
          for (let c = c0, i = r * cols + c0; c <= c1; c++, i++) {
            const dx = hx[i] + ox[i] - p.x;
            const dy = hy[i] + oy[i] - p.y;
            const d = Math.hypot(dx, dy);
            if (d >= R) continue;
            const t = 1 - d / R;
            const weight = t * t * (3 - 2 * t) * f.presence; // no hard edge at the rim
            const s = d > 0.5 ? (k * PRESS * (R - d) * weight) / d : 0;
            const side = s * grain[i] * 0.3; // a little sideways, like grains rolling off
            fx[i] += dx * s - dy * side + p.vx * DRAG * weight;
            fy[i] += dy * s + dx * side + p.vy * DRAG * weight;
          }
        }
      }
      // The rings, computed from scratch each frame
      const { wx, wy, we } = f;
      wx.fill(0);
      wy.fill(0);
      we.fill(0);
      for (const q of f.pulses) {
        const age = f.time - q.t;
        const front = age * WAVE_SPEED;
        const fade = (1 - (age / WAVE_LIFE) ** 2) * q.power; // holds, then lets go: it crosses the whole field
        const outer = (front + WAVE_WIDTH) ** 2;
        const inner = Math.max(front - WAVE_WIDTH, 0) ** 2;
        for (let i = 0; i < f.count; i++) {
          const dx = hx[i] - q.x; // from home: the ring stays round
          const dy = hy[i] - q.y;
          const d2 = dx * dx + dy * dy;
          if (d2 > outer || d2 < inner) continue;
          const d = Math.sqrt(d2) || 1;
          const crest = Math.cos(((d - front) / WAVE_WIDTH) * (Math.PI / 2)) ** 2 * fade;
          wx[i] += (dx / d) * WAVE * crest;
          wy[i] += (dy / d) * WAVE * crest;
          we[i] = Math.max(we[i], crest);
        }
      }
    },

    // A dot pushed further is darker (same size by default): the ridge and the ring read at a glance.
    // Under the text only that lift is calmed: at rest, the field runs on behind the words
    draw(f, ctx) {
      const { hx, hy, ox, oy, wx, wy, we, quiet, calm, count, shade, size, rest, lift, grow } = f;
      const used = new Uint16Array(SHADES * QUIET);
      const open = calm < 1 ? 1 / (1 - calm) : 0;
      for (let i = 0; i < count; i++) {
        const e = Math.round(Math.min(Math.hypot(ox[i], oy[i]) / 22 + we[i], 1) * (SHADES - 1));
        const q = e ? Math.round((quiet[i] - calm) * open * (QUIET - 1)) : QUIET - 1;
        shade[i] = e * QUIET + q;
        used[shade[i]]++;
      }
      ctx.fillStyle = f.ink;
      const peak = rest + (1 - rest) * lift;
      for (let level = 0; level < SHADES * QUIET; level++) {
        if (!used[level]) continue;
        const e = Math.floor(level / QUIET) / (SHADES - 1);
        const q = open ? calm + (1 - calm) * ((level % QUIET) / (QUIET - 1)) : 1;
        const radius = size * (1 + grow * e * q);
        ctx.globalAlpha = rest + (peak - rest) * e * q;
        ctx.beginPath();
        for (let i = 0; i < count; i++) {
          if (shade[i] !== level) continue;
          const x = hx[i] + ox[i] + wx[i];
          const y = hy[i] + oy[i] + wy[i];
          ctx.moveTo(x + radius, y);
          ctx.arc(x, y, radius, 0, Math.PI * 2);
        }
        ctx.fill();
      }
      ctx.globalAlpha = 1;
    },
  };

  // 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) {
    field(root, signal, dots);
  }

  // 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.
  const instances = new WeakMap();
  const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
  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();
    instances.set(root, { controller, html: root.innerHTML, attrs: [...root.attributes].map((a) => [a.name, a.value]) });
    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);
    instance.controller.abort();
    [...root.attributes].forEach((a) => root.removeAttribute(a.name));
    instance.attrs.forEach(([name, value]) => root.setAttribute(name, value));
    root.innerHTML = instance.html;
  });
  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).
