# Eye Glyph

> Any letter with a hole becomes an eye: the pupil follows the pointer, lashes grow on top, and real lids close it when it blinks. Poke one and it winks.

Text effects · eye, blink, 404, cursor, headline, playful · HTML, CSS, vanilla JS · no dependencies · updated 2026-10-21
Demo: https://kodza.dev/components/eye-glyph/

## Files

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

- [`kodza-eye-glyph.css`](https://kodza.dev/registry/eye-glyph/kodza-eye-glyph.css) · 8.0 KB
- [`kodza-eye-glyph.js`](https://kodza.dev/registry/eye-glyph/kodza-eye-glyph.js) · 17.0 KB
- [`kodza-eye-glyph.html`](https://kodza.dev/registry/eye-glyph/kodza-eye-glyph.html) · 0.2 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-eye-glyph.css https://kodza.dev/registry/eye-glyph/kodza-eye-glyph.css
curl -fsSL --create-dirs -o kodza/kodza-eye-glyph.js https://kodza.dev/registry/eye-glyph/kodza-eye-glyph.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-eye-glyph]`
- Variables (set them on the root element itself: it declares its own defaults):
  - `--kz-eye-glyph-ink: #0a0a0a`
  - `--kz-eye-glyph-pupil: 0.55` — pupil size, from the narrow side of the hole
  - `--kz-eye-glyph-lashes: 1` — lash length; 0 hides them
  - `--kz-eye-glyph-blink: 340ms`
  - `--kz-eye-glyph-every: 4.2s` — average time between two blinks
- Attributes:
  - `data-kodza-eye-glyph`
- 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-eye-glyph {
    --kz-eye-glyph-ink: #f2f2f0;
  }
}
```

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

## Use it

### Plain HTML page

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

#### With Elementor

- HTML: an **HTML** widget (Elementor shows it to administrators only). It keeps the code exactly as pasted, straight quotes included.
- 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-eye-glyph.css` and `assets/kodza-eye-glyph.js` (the Files commands with `-o assets/…`).
- In the section: `{{ 'kodza-eye-glyph.css' | asset_url | stylesheet_tag }}` and `<script src="{{ 'kodza-eye-glyph.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/`): `EyeGlyph.tsx` (below), the CSS block under Code saved as `eye-glyph.css` and the JS block as `eye-glyph.js`, both as they are. The HTML block is already inside the component: skip it.
- Use it: `import { EyeGlyph } from '@/components/kodza/EyeGlyph';` then `<EyeGlyph />`. Props: `vars` (the `--kz-…` variables listed under API, e.g. `vars={{ '--kz-eye-glyph-ink': … }}`), `className`, `style` and the other props of its `<p>` (`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 './eye-glyph.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 — Eye Glyph, for React. From https://kodza.dev/components/eye-glyph/
// 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: eye-glyph.css and eye-glyph.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 './eye-glyph.css';

export type EyeGlyphProps = Omit<ComponentPropsWithoutRef<'p'>, "children" | "vars"> & {
  /** Settings, as CSS variables on the root */
  vars?: {
    /** default: #0a0a0a */
    "--kz-eye-glyph-ink"?: string | number;
    /** pupil size, from the narrow side of the hole — default: 0.55 */
    "--kz-eye-glyph-pupil"?: string | number;
    /** lash length; 0 hides them — default: 1 */
    "--kz-eye-glyph-lashes"?: string | number;
    /** default: 340ms */
    "--kz-eye-glyph-blink"?: string | number;
    /** average time between two blinks — default: 4.2s */
    "--kz-eye-glyph-every"?: string | number;
  };
  className?: string;
};

export function EyeGlyph({ vars, className, style, ...rest }: EyeGlyphProps) {
  const ref = useRef<ComponentRef<'p'>>(null);
  const classes = ["kodza-eye-glyph", 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('./eye-glyph.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 (
    <p data-kodza-eye-glyph="" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>4<span className="kodza-eye-glyph__eye">0</span>4</p>
  );
}
```

## Code

```html
<p class="kodza-eye-glyph" data-kodza-eye-glyph>4<span class="kodza-eye-glyph__eye">0</span>4</p>

<p class="kodza-eye-glyph" data-kodza-eye-glyph>L<span class="kodza-eye-glyph__eye">O</span><span class="kodza-eye-glyph__eye">O</span>K</p>
```

```css
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Eye Glyph
   Any letter with a hole (0, o, O) becomes an eye: the pupil follows the pointer, lashes grow on
   top, and two real lids close the hole when it blinks. Eyes in the same word blink together;
   poke one and it winks. Everything is tuned with the --kz-eye-glyph-* variables. */
.kodza-eye-glyph {
  /* Keep the ink opaque: the lids are drawn over the letter. Fade the word with opacity instead */
  --kz-eye-glyph-ink: #0a0a0a;
  --kz-eye-glyph-pupil: 0.55; /* pupil size, from the narrow side of the hole */
  --kz-eye-glyph-lashes: 1; /* lash length; 0 hides them */
  --kz-eye-glyph-blink: 340ms;
  --kz-eye-glyph-every: 4.2s; /* average time between two blinks */

  color: var(--kz-eye-glyph-ink);
}

.kodza-eye-glyph__eye {
  position: relative;
  display: inline-block;
}

/* Where the baseline is: the script measures the hole of the letter from here */
.kodza-eye-glyph__base {
  display: inline-block;
  width: 0;
  height: 0;
}

/* The pupil, the lids and the lashes are placed by the script, in px (--kz-eye-glyph-x…) */
.kodza-eye-glyph__pupil {
  position: absolute;
  left: var(--kz-eye-glyph-x, 50%);
  top: var(--kz-eye-glyph-y, 50%);
  width: var(--kz-eye-glyph-d, 0.12em);
  height: var(--kz-eye-glyph-d, 0.12em);
  margin: calc(var(--kz-eye-glyph-d, 0.12em) / -2) 0 0 calc(var(--kz-eye-glyph-d, 0.12em) / -2);
  border-radius: 50%;
  background: currentColor;
  opacity: 0;
  will-change: transform;
}

.kodza-eye-glyph__lids,
.kodza-eye-glyph__lashes {
  position: absolute;
  overflow: visible;
  pointer-events: none;
}

.kodza-eye-glyph__lids {
  fill: currentColor;
}

.kodza-eye-glyph__lashes {
  left: 0;
  top: 0;
  opacity: 0;
}

.kodza-eye-glyph__lashes path {
  fill: none;
  stroke: currentColor;
  stroke-width: var(--kz-eye-glyph-lash-width, 0.032em);
  stroke-linecap: round;
}

.kodza-eye-glyph__lashes g {
  transform-box: fill-box;
  transform-origin: 50% 100%;
}

/* The eye opens once it is measured: nothing shows up in the wrong place first */
.kodza-eye-glyph__eye.is-open .kodza-eye-glyph__pupil,
.kodza-eye-glyph__eye.is-open .kodza-eye-glyph__lashes {
  opacity: 1;
  transition: opacity 0.45s ease;
}

@media (prefers-reduced-motion: reduce) {
  .kodza-eye-glyph *,
  .kodza-eye-glyph *::before,
  .kodza-eye-glyph *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
}

/* kodza-guard 7d2ab535526c — 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-eye-glyph__base):not(#_),
:where(.kodza-eye-glyph__lids):not(#_) {
  transition-duration: revert;
}

:where(.kodza-eye-glyph__pupil):not(#_),
:where(.kodza-eye-glyph__lashes):not(#_) {
  transition: revert;
}

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

.kodza-eye-glyph:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-height: 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-eye-glyph__eye:not(#_) {
  box-sizing: revert;
}

.kodza-eye-glyph__base:not(#_) {
  border: revert;
  margin: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: 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;
  transition-property: revert;
  transition-timing-function: revert;
  transition-delay: revert;
}

.kodza-eye-glyph__pupil:not(#_) {
  border: revert;
  padding: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  float: 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-eye-glyph__lids:not(#_) {
  border: revert;
  margin: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: 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-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;
  transition-property: revert;
  transition-timing-function: revert;
  transition-delay: revert;
}

.kodza-eye-glyph__lashes:not(#_) {
  border: revert;
  margin: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: 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-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-eye-glyph:not(#_):where(:not([hidden])),
.kodza-eye-glyph__pupil:not(#_):where(:not([hidden])),
.kodza-eye-glyph__lids:not(#_):where(:not([hidden])),
.kodza-eye-glyph__lashes:not(#_):where(:not([hidden])) {
  display: revert;
}

.kodza-eye-glyph:not(#_):where([hidden]),
.kodza-eye-glyph__pupil:not(#_):where([hidden]),
.kodza-eye-glyph__lids:not(#_):where([hidden]),
.kodza-eye-glyph__lashes:not(#_):where([hidden]) {
  display: none;
}

/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */

.kodza-eye-glyph:not(#_) {
  color: var(--kz-eye-glyph-ink);
}

.kodza-eye-glyph__base:not(#_) {
  display: inline-block;
}

.kodza-eye-glyph__pupil:not(#_) {
  position: absolute;
  margin: calc(var(--kz-eye-glyph-d, 0.12em) / -2) 0 0 calc(var(--kz-eye-glyph-d, 0.12em) / -2);
  border-radius: 50%;
  background: currentColor;
}

.kodza-eye-glyph__lids:not(#_),
.kodza-eye-glyph__lashes:not(#_) {
  position: absolute;
}

.kodza-eye-glyph__eye.is-open .kodza-eye-glyph__pupil:not(#_),
.kodza-eye-glyph__eye.is-open .kodza-eye-glyph__lashes:not(#_) {
  transition: opacity 0.45s ease;
}

@media (prefers-reduced-motion: reduce) {
  .kodza-eye-glyph *:where(i, svg):not(#_) {
    transition-duration: 0.01ms !important;
  }
}
```

```js
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Eye Glyph
// Each letter marked __eye becomes an eye. Its hole, measured on its own font at any size, gets a
// pupil that follows the pointer (or looks around when nothing moves), lashes grow on top of the
// letter, and two lids close the hole on a curved line when it blinks. "kodza:replay" on the
// root makes it blink.
(() => {
  const SELECTOR = '[data-kodza-eye-glyph]';
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const NS = 'http://www.w3.org/2000/svg';
  const CLOSE = 'cubic-bezier(0.55, 0, 0.85, 0.35)';
  const OPEN = 'cubic-bezier(0.15, 0.7, 0.3, 1)';

  const svg = (tag, attrs = {}) => {
    const node = document.createElementNS(NS, tag);
    Object.entries(attrs).forEach(([k, v]) => node.setAttribute(k, v));
    return node;
  };
  const time = (value, fallback) => {
    const n = parseFloat(value);
    if (Number.isNaN(n)) return fallback;
    return /ms\s*$/.test(value) ? n : n * 1000;
  };

  // The letter drawn on a canvas, in its own font: the hole row by row, the stroke width, and the
  // top of the outline where the lashes grow. In px, from the glyph origin and its baseline.
  function measure(el) {
    const cs = getComputedStyle(el);
    let char = el.textContent.trim().charAt(0);
    if (cs.textTransform === 'uppercase') char = char.toUpperCase();
    if (cs.textTransform === 'lowercase') char = char.toLowerCase();
    if (!char) return null;
    const S = 240;
    const font = `${cs.fontStyle} ${cs.fontWeight} ${S}px ${cs.fontFamily}`;
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d', { willReadFrequently: true });
    ctx.font = font;
    const m = ctx.measureText(char);
    const pad = 4;
    const w = Math.ceil(m.actualBoundingBoxLeft + m.actualBoundingBoxRight) + pad * 2;
    const h = Math.ceil(m.actualBoundingBoxAscent + m.actualBoundingBoxDescent) + pad * 2;
    if (w < 8 || h < 8) return null;
    canvas.width = w;
    canvas.height = h;
    ctx.font = font;
    const ox = m.actualBoundingBoxLeft + pad;
    const oy = m.actualBoundingBoxAscent + pad;
    ctx.fillText(char, ox, oy);
    const px = ctx.getImageData(0, 0, w, h).data;
    const ink = (x, y) => px[(y * w + x) * 4 + 3] > 127;
    const cx = Math.round(w / 2);
    const cy = Math.round(h / 2);
    if (ink(cx, cy)) return null; // no hole in the middle (a slashed zero, an L): no eye

    let [t, b] = [cy, cy];
    while (t > 0 && !ink(cx, t - 1)) t--;
    while (b < h - 1 && !ink(cx, b + 1)) b++;
    if (t === 0 || b === h - 1) return null; // an open shape (a C, a U): not a hole
    const rows = [];
    for (let y = t; y <= b; y++) {
      let [l, r] = [cx, cx];
      while (l > 0 && !ink(l - 1, y)) l--;
      while (r < w - 1 && !ink(r + 1, y)) r++;
      rows.push([y, l, r + 1]);
    }
    const mid = rows[Math.round((b - t) / 2)];
    let outL = mid[1];
    while (outL > 0 && ink(outL - 1, mid[0])) outL--;
    let outR = mid[2];
    while (outR < w && ink(outR, mid[0])) outR++;
    const top = (x) => {
      let y = 0;
      while (y < t && !ink(x, y)) y++;
      return y;
    };

    const k = parseFloat(cs.fontSize) / S;
    const X = (x) => (x - ox) * k;
    const Y = (y) => (y - oy) * k;
    const oc = (outL + outR) / 2;
    const orx = (outR - outL) / 2;
    // Lashes grow where the top of the outline faces the sky, each along its normal
    const lashes = [-0.62, -0.31, 0, 0.31, 0.62].map((f) => {
      const x = Math.round(oc + f * orx);
      const dy = top(x + 3) - top(x - 3);
      const n = Math.hypot(6, dy);
      return { x: X(x), y: Y(top(x)), nx: dy / n, ny: -6 / n, f };
    });
    return {
      rows: rows.map(([y, l, r]) => [Y(y), X(l), X(r)]),
      x: X((mid[1] + mid[2]) / 2),
      y: Y((t + b + 1) / 2),
      rx: ((mid[2] - mid[1]) / 2) * k,
      ry: ((b + 1 - t) / 2) * k,
      stroke: (mid[1] - outL) * k,
      em: parseFloat(cs.fontSize),
      lashes,
    };
  }

  // One eye: its pupil, lids and lashes, laid on the hole of its letter
  function makeEye(el, root) {
    const base = document.createElement('i');
    base.className = 'kodza-eye-glyph__base';
    const pupil = document.createElement('i');
    pupil.className = 'kodza-eye-glyph__pupil';
    pupil.setAttribute('aria-hidden', 'true');
    const lids = svg('svg', { class: 'kodza-eye-glyph__lids', 'aria-hidden': 'true', focusable: 'false' });
    const upper = svg('path');
    const lower = svg('path');
    lids.append(upper, lower);
    const lashes = svg('svg', { class: 'kodza-eye-glyph__lashes', 'aria-hidden': 'true', focusable: 'false' });
    const lashSet = svg('g');
    lashes.append(lashSet);
    el.prepend(base);
    el.append(pupil, lids, lashes);

    const eye = {
      el,
      geo: null, // the pupil: its centre and how far it travels, in px
      lid: null, // the lids: open and shut, in px
      at: { x: 0, y: 0 }, // where the pupil is, from -1 to 1 on each axis
      look: { x: 0, y: 0 }, // where it is heading
    };

    eye.place = () => {
      const z = measure(el);
      if (!z) {
        eye.geo = null;
        el.classList.remove('is-open');
        return;
      }
      const cs = getComputedStyle(root);
      const bx = base.offsetLeft;
      const by = base.offsetTop;

      // The pupil
      const size = parseFloat(cs.getPropertyValue('--kz-eye-glyph-pupil'));
      const d = Math.min(z.rx, z.ry) * 2 * (Number.isNaN(size) ? 0.55 : size);
      const gap = d * 0.14;
      eye.geo = {
        x: bx + z.x,
        y: by + z.y,
        tx: Math.max(0, z.rx - d / 2 - gap),
        ty: Math.max(0, z.ry - d / 2 - gap),
      };
      el.style.setProperty('--kz-eye-glyph-x', `${eye.geo.x}px`);
      el.style.setProperty('--kz-eye-glyph-y', `${eye.geo.y}px`);
      el.style.setProperty('--kz-eye-glyph-d', `${d}px`);

      // The lids: a layer the size of the hole (a little more, under the stroke), cut to its shape
      const e = z.stroke * 0.4;
      const x0 = Math.min(...z.rows.map((r) => r[1])) - e;
      const y0 = z.rows[0][0] - e;
      const W = Math.max(...z.rows.map((r) => r[2])) + e - x0;
      const H = z.rows[z.rows.length - 1][0] + e - y0;
      const step = Math.max(1, Math.round(z.rows.length / 48));
      const sample = z.rows.filter((_, i) => i % step === 0 || i === z.rows.length - 1);
      const pt = (x, y) => `${(x - x0).toFixed(1)}px ${(y - y0).toFixed(1)}px`;
      const first = sample[0];
      const last = sample[sample.length - 1];
      const shape = [
        pt(first[1] - e, y0),
        ...sample.map(([y, l]) => pt(l - e, y)),
        pt(last[1] - e, y0 + H),
        pt(last[2] + e, y0 + H),
        ...sample.slice().reverse().map(([y, , r]) => pt(r + e, y)),
        pt(first[2] + e, y0),
      ];
      Object.assign(lids.style, {
        left: `${bx + x0}px`,
        top: `${by + y0}px`,
        width: `${W}px`,
        height: `${H}px`,
        clipPath: `polygon(${shape.join(', ')})`,
      });
      lids.setAttribute('viewBox', `0 0 ${W} ${H}`);
      // Both lids share one edge, dipping in the middle: shut, a thin curved line is left (‿)
      const sag = Math.min(W * 0.3, H * 0.16);
      const p = W * 0.08;
      const edge = `Q ${W / 2} ${sag * 2} ${-p} 0`;
      upper.setAttribute('d', `M ${-p} ${-3 * H} H ${W + p} V 0 ${edge} Z`);
      lower.setAttribute('d', `M ${-p} ${3 * H} H ${W + p} V 0 ${edge} Z`);
      const shut = H * 0.7 - sag; // the closing line, three quarters down the hole
      eye.lid = {
        upperOpen: -sag - 2,
        upperShut: shut,
        lowerOpen: H + 2,
        lowerShut: shut + z.em * 0.007,
        dip: z.em * 0.02,
      };
      upper.style.transform = `translateY(${eye.lid.upperOpen}px)`;
      lower.style.transform = `translateY(${eye.lid.lowerOpen}px)`;

      // The lashes: planted in the top of the outline, a little wider than its normal and curled
      // outwards, like real ones; round ends
      const scale = parseFloat(cs.getPropertyValue('--kz-eye-glyph-lashes'));
      const len = z.em * 0.095 * (Number.isNaN(scale) ? 1 : scale);
      const into = z.em * 0.05;
      Object.assign(lashes.style, { width: `${el.offsetWidth}px`, height: `${el.offsetHeight}px` });
      lashes.setAttribute('viewBox', `0 0 ${el.offsetWidth} ${el.offsetHeight}`);
      lashes.style.setProperty('--kz-eye-glyph-lash-width', `${z.em * 0.032}px`);
      const f2 = (n) => n.toFixed(1);
      lashSet.replaceChildren(
        ...(len > 0
          ? z.lashes.map(({ x, y, nx, ny, f }) => {
              const a = f * 0.3;
              const ux = nx * Math.cos(a) - ny * Math.sin(a);
              const uy = nx * Math.sin(a) + ny * Math.cos(a);
              const l = len * (1 - Math.abs(f) * 0.25);
              const curl = -Math.sign(f) * l * 0.2; // outwards, square to the lash
              const [ax, ay] = [bx + x - ux * into, by + y - uy * into];
              const [qx, qy] = [bx + x + ux * l * 0.55 - uy * curl, by + y + uy * l * 0.55 + ux * curl];
              const [tx, ty] = [bx + x + ux * l, by + y + uy * l];
              return svg('path', { d: `M ${f2(ax)} ${f2(ay)} Q ${f2(qx)} ${f2(qy)} ${f2(tx)} ${f2(ty)}` });
            })
          : [])
      );

      el.classList.add('is-open');
      eye.draw();
    };

    eye.draw = () => {
      if (eye.geo) pupil.style.transform = `translate(${eye.at.x * eye.geo.tx}px, ${eye.at.y * eye.geo.ty}px)`;
    };

    // The blink: the upper lid drops fast, the lower one rises a little, they meet on a curved
    // line, then the eye opens again more slowly; the lashes follow the lid
    eye.blink = (duration, then) => {
      if (!eye.lid) return;
      const { upperOpen, upperShut, lowerOpen, lowerShut, dip } = eye.lid;
      const move = (from, to) => [
        { transform: `translateY(${from}px)`, easing: CLOSE },
        { transform: `translateY(${to}px)`, offset: 0.34 },
        { transform: `translateY(${to}px)`, offset: 0.44, easing: OPEN },
        { transform: `translateY(${from}px)` },
      ];
      const a = upper.animate(move(upperOpen, upperShut), duration);
      lower.animate(move(lowerOpen, lowerShut), duration);
      lashSet.animate(
        [
          { transform: 'none', easing: CLOSE },
          { transform: `translateY(${dip}px) scaleY(0.82)`, offset: 0.34 },
          { transform: `translateY(${dip}px) scaleY(0.82)`, offset: 0.44, easing: OPEN },
          { transform: `translateY(${-dip * 0.2}px)`, offset: 0.8 },
          { transform: 'none' },
        ],
        duration
      );
      if (then) a.onfinish = then;
    };

    return eye;
  }

  // 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) {
    const eyes = [...root.querySelectorAll('.kodza-eye-glyph__eye')].map((el) => makeEye(el, root));
    if (!eyes.length) return;
    const timers = new Set();
    const later = (fn, ms) => {
      const t = setTimeout(() => {
        timers.delete(t);
        fn();
      }, ms);
      timers.add(t);
    };
    let raf = 0;
    let speed = 0.16;
    let visible = false;
    let awake = false;
    let lastMove = 0;

    const placeAll = () => eyes.forEach((eye) => eye.place());
    placeAll();
    document.fonts?.ready.then(() => !signal.aborted && placeAll());
    const ro = new ResizeObserver((entries) =>
      entries.forEach((entry) => eyes.find((eye) => eye.el === entry.target)?.place())
    );
    eyes.forEach((eye) => ro.observe(eye.el));
    signal.addEventListener('abort', () => {
      ro.disconnect();
      cancelAnimationFrame(raf);
      timers.forEach(clearTimeout);
    });
    if (reduceMotion) return; // the eyes stay open and look straight ahead

    const frame = () => {
      let moving = false;
      eyes.forEach((eye) => {
        eye.at.x += (eye.look.x - eye.at.x) * speed;
        eye.at.y += (eye.look.y - eye.at.y) * speed;
        if (Math.abs(eye.look.x - eye.at.x) < 0.002 && Math.abs(eye.look.y - eye.at.y) < 0.002) {
          eye.at.x = eye.look.x;
          eye.at.y = eye.look.y;
        } else moving = true;
        eye.draw();
      });
      raf = moving ? requestAnimationFrame(frame) : 0;
    };
    const go = (s) => {
      speed = s;
      if (!raf) raf = requestAnimationFrame(frame);
    };
    const set = (eye, x, y) => {
      const n = Math.hypot(x, y);
      eye.look.x = n > 1 ? x / n : x;
      eye.look.y = n > 1 ? y / n : y;
    };

    // Each eye looks at the pointer on its own: a little away, the pupil is already near the edge
    // of the hole; right on the eye, it aims true; two eyes cross when the pointer is between them
    window.addEventListener(
      'pointermove',
      (event) => {
        if (event.pointerType === 'touch' || !visible) return;
        lastMove = performance.now();
        eyes.forEach((eye) => {
          if (!eye.geo) return;
          const box = eye.el.getBoundingClientRect();
          const scale = box.width / eye.el.offsetWidth || 1;
          const dx = event.clientX - (box.left + eye.geo.x * scale);
          const dy = event.clientY - (box.top + eye.geo.y * scale);
          const d = Math.hypot(dx, dy);
          const near = parseFloat(getComputedStyle(eye.el).fontSize) * 0.25 * scale;
          const m = d < 0.5 ? 0 : 1 / (d + near);
          set(eye, dx * m, dy * m);
        });
        go(0.16);
      },
      { signal, passive: true }
    );

    // Blinks: all the eyes of a word together, from time to time, twice in a row one time in four
    const duration = () => time(getComputedStyle(root).getPropertyValue('--kz-eye-glyph-blink'), 340);
    const blinkAll = (twice) => {
      const d = duration();
      eyes.forEach((eye, i) => eye.blink(d, twice && i === 0 ? () => later(() => blinkAll(false), 90) : null));
    };
    let quiet = 0; // after a wink, no blink on its own for a while: the wink stays readable
    const blinks = () => {
      if (visible && !document.hidden && performance.now() > quiet) blinkAll(Math.random() < 0.25);
      const every = time(getComputedStyle(root).getPropertyValue('--kz-eye-glyph-every'), 4200);
      later(blinks, every * (0.55 + Math.random() * 0.9));
    };
    // Poke one: it winks, on its own. "kodza:replay" on the root blinks them all, on cue
    eyes.forEach((eye) =>
      eye.el.addEventListener(
        'pointerdown',
        () => {
          quiet = performance.now() + 1800;
          eye.blink(duration());
        },
        { signal }
      )
    );
    root.addEventListener('kodza:replay', () => blinkAll(false), { signal });

    // With no pointer moving, they look around in small jumps, and sometimes back at you
    const wander = () => {
      if (visible && !document.hidden && performance.now() - lastMove > 2600) {
        if (Math.random() < 0.3) eyes.forEach((eye) => set(eye, 0, 0));
        else {
          const angle = Math.random() * Math.PI * 2;
          const r = 0.55 + Math.random() * 0.45;
          eyes.forEach((eye) => set(eye, Math.cos(angle) * r, Math.sin(angle) * r));
        }
        go(0.3);
      }
      later(wander, 1400 + Math.random() * 2000);
    };

    // They wake up the first time they are seen: a double blink, then the rest of their life
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (!visible || awake) return;
      awake = true;
      later(() => {
        blinkAll(true);
        later(blinks, 2400);
        later(wander, 1800);
      }, 450);
    });
    io.observe(root);
    signal.addEventListener('abort', () => io.disconnect());
  }

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