# Hero Focus

> A title sharp over a blurred picture. On scroll the focus moves to the picture, the title blurs and leaves, a readout counts the distance, then notes point out the details. It rewinds.

Hero · hero, section, landing, scroll, sticky, pin, image, focus, blur, annotations, scrub · HTML, CSS, vanilla JS · no dependencies · updated 2026-10-24
Demo: https://kodza.dev/components/hero-focus/

Takes the whole screen (or reacts to the page scroll): give it a page, not a small box.

## Files

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

- [`kodza-hero-focus.css`](https://kodza.dev/registry/hero-focus/kodza-hero-focus.css) · 21.5 KB
- [`kodza-hero-focus.js`](https://kodza.dev/registry/hero-focus/kodza-hero-focus.js) · 21.8 KB
- [`kodza-hero-focus.html`](https://kodza.dev/registry/hero-focus/kodza-hero-focus.html) · 3.3 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-hero-focus.css https://kodza.dev/registry/hero-focus/kodza-hero-focus.css
curl -fsSL --create-dirs -o kodza/kodza-hero-focus.js https://kodza.dev/registry/hero-focus/kodza-hero-focus.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: `.kodza-hero-focus`
- Variables (set them on the root element itself: it declares its own defaults):
  - `--kz-hero-focus-ink: #0a0a0a`
  - `--kz-hero-focus-paper: #f2f2f0`
  - `--kz-hero-focus-dim: rgba(10, 10, 10, 0.66)`
  - `--kz-hero-focus-line: rgba(10, 10, 10, 0.12)`
  - `--kz-hero-focus-card: #fff`
  - `--kz-hero-focus-veil: 242, 242, 240` — the readability veil, rgb of the paper
  - `--kz-hero-focus-ratio: 1` — the picture's width / height
  - `--kz-hero-focus-crop-x: 0.5` — which part stays in view when the screen crops it, 0 to 1
  - `--kz-hero-focus-crop-y: 0.45`
  - `--kz-hero-focus-blur: 18px` — how soft the picture is behind the words
  - `--kz-hero-focus-gutter: clamp(16px, 4vw, 40px)`
  - `--kz-hero-focus-pad-y: clamp(20px, 3vw, 40px)`
  - `--kz-hero-focus-top: 0px` — height of a fixed site header: the scene pins under it
  - `--kz-hero-focus-distance: 200svh` — how long the scene stays pinned
  - `--kz-hero-focus-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace`
  - `--kz-hero-focus-ease: cubic-bezier(0.22, 1, 0.36, 1)`
- Attributes:
  - `data-place` (e.g. `left`, `below`, `above`)
  - `data-side` (e.g. `right`, `left`)
- Lifecycle: `kodza:init` / `kodza:destroy` on `document`, with `{ detail: { root } }` to target a container.

## Keyboard

What each key does, by where the focus is. Keep it working when you adapt the HTML: same elements, same classes.

- On the button (focus in `.kodza-hero-focus__button`):
  - `Enter`: Follows the link

## 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-hero-focus {
    --kz-hero-focus-ink: #f2f2f0;
    --kz-hero-focus-paper: #0a0a0a;
    --kz-hero-focus-dim: rgba(242, 242, 240, 0.66);
    --kz-hero-focus-line: rgba(255, 255, 255, 0.14);
    --kz-hero-focus-card: #161616;
    --kz-hero-focus-veil: 10, 10, 10;
  }
}
```

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

Its images have dark versions next to them (`kz-overflow-2400.webp` → `kz-overflow-dark-2400.webp`). To swap them on a dark page: `<picture><source media="(prefers-color-scheme: dark)" srcset="…-dark-….webp"><img …></picture>`.

## Use it

### Plain HTML page

- CSS: `kodza/kodza-hero-focus.css` next to the page (the Files commands as they are), linked in `<head>`: `<link rel="stylesheet" href="kodza/kodza-hero-focus.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-hero-focus.js`, loaded with `<script src="kodza/kodza-hero-focus.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-hero-focus.css' -e 'd' -e '}' -e '/^KODZA-JS$/{r kodza/kodza-hero-focus.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">`.
- Images: the artifact blocks every other host, `kodza.dev` included, without an error. Embed each one as a `data:` URI, with a command if you can run one (`curl -s <url> | base64`; the 1400 px files weigh 10 to 30 KB), never by typing base64 out. If you can't run commands, keep the `<img>` with its `width` and `height`, and tell the user the picture stays blank inside the artifact.
- 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-hero-focus', $uri . 'kodza-hero-focus.css', [], filemtime($dir . 'kodza-hero-focus.css'));
    wp_enqueue_script('kodza-hero-focus', $uri . 'kodza-hero-focus.js', [], filemtime($dir . 'kodza-hero-focus.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.
- 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-hero-focus.css` and `assets/kodza-hero-focus.js` (the Files commands with `-o assets/…`).
- In the section: `{{ 'kodza-hero-focus.css' | asset_url | stylesheet_tag }}` and `<script src="{{ 'kodza-hero-focus.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/`): `HeroFocus.tsx` (below), the CSS block under Code saved as `hero-focus.css` and the JS block as `hero-focus.js`, both as they are. The HTML block is already inside the component: skip it.
- Use it: `import { HeroFocus } from '@/components/kodza/HeroFocus';` then `<HeroFocus />`. Props: `vars` (the `--kz-…` variables listed under API, e.g. `vars={{ '--kz-hero-focus-ink': … }}`), `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 './hero-focus.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 — Hero Focus, for React. From https://kodza.dev/components/hero-focus/
// 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: hero-focus.css and hero-focus.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 './hero-focus.css';

export type HeroFocusProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars"> & {
  /** Settings, as CSS variables on the root */
  vars?: {
    /** default: #0a0a0a */
    "--kz-hero-focus-ink"?: string | number;
    /** default: #f2f2f0 */
    "--kz-hero-focus-paper"?: string | number;
    /** default: rgba(10, 10, 10, 0.66) */
    "--kz-hero-focus-dim"?: string | number;
    /** default: rgba(10, 10, 10, 0.12) */
    "--kz-hero-focus-line"?: string | number;
    /** default: #fff */
    "--kz-hero-focus-card"?: string | number;
    /** the readability veil, rgb of the paper — default: 242, 242, 240 */
    "--kz-hero-focus-veil"?: string | number;
    /** the picture's width / height — default: 1 */
    "--kz-hero-focus-ratio"?: string | number;
    /** which part stays in view when the screen crops it, 0 to 1 — default: 0.5 */
    "--kz-hero-focus-crop-x"?: string | number;
    /** default: 0.45 */
    "--kz-hero-focus-crop-y"?: string | number;
    /** how soft the picture is behind the words — default: 18px */
    "--kz-hero-focus-blur"?: string | number;
    /** default: clamp(16px, 4vw, 40px) */
    "--kz-hero-focus-gutter"?: string | number;
    /** default: clamp(20px, 3vw, 40px) */
    "--kz-hero-focus-pad-y"?: string | number;
    /** height of a fixed site header: the scene pins under it — default: 0px */
    "--kz-hero-focus-top"?: string | number;
    /** how long the scene stays pinned — default: 200svh */
    "--kz-hero-focus-distance"?: string | number;
    /** default: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace */
    "--kz-hero-focus-mono"?: string | number;
    /** default: cubic-bezier(0.22, 1, 0.36, 1) */
    "--kz-hero-focus-ease"?: string | number;
  };
  className?: string;
};

export function HeroFocus({ vars, className, style, ...rest }: HeroFocusProps) {
  const ref = useRef<ComponentRef<'section'>>(null);
  const classes = ["kodza-hero-focus", 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('./hero-focus.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 key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
      <div className="kodza-hero-focus__scene">
        <div className="kodza-hero-focus__media">
          <div className="kodza-hero-focus__lens">
            <div className="kodza-hero-focus__frame">
              <img className="kodza-hero-focus__img" src="https://kodza.dev/media/kz-overflow-2400.webp" alt="A two-storey concrete house, its upper floor cantilevered over the ground" width="2400" height="2400" />
              <ol className="kodza-hero-focus__notes">
                <li className="kodza-hero-focus__note" style={{ "--x": "0.6921", "--y": "0.4821" } as CSSProperties} data-side="right">
                  <span className="kodza-hero-focus__dot" />
                  <span className="kodza-hero-focus__wire" />
                  {' '}
                  <span className="kodza-hero-focus__card">
                    <span className="kodza-hero-focus__mask">
                      <span className="kodza-hero-focus__rise kodza-hero-focus__label">01 — Cantilever</span>
                    </span>
                    <span className="kodza-hero-focus__mask">
                      <span className="kodza-hero-focus__rise kodza-hero-focus__text">3.8 m out. Nothing underneath.</span>
                    </span>
                  </span>
                </li>
                <li className="kodza-hero-focus__note" style={{ "--x": "0.4979", "--y": "0.3861" } as CSSProperties} data-side="left">
                  <span className="kodza-hero-focus__dot" />
                  <span className="kodza-hero-focus__wire" />
                  {' '}
                  <span className="kodza-hero-focus__card">
                    <span className="kodza-hero-focus__mask">
                      <span className="kodza-hero-focus__rise kodza-hero-focus__label">02 — Ribbon window</span>
                    </span>
                    <span className="kodza-hero-focus__mask">
                      <span className="kodza-hero-focus__rise kodza-hero-focus__text">6.8 m of glass in one line. The view does the rest.</span>
                    </span>
                  </span>
                </li>
                <li className="kodza-hero-focus__note" style={{ "--x": "0.3261", "--y": "0.5895" } as CSSProperties} data-side="left">
                  <span className="kodza-hero-focus__dot" />
                  <span className="kodza-hero-focus__wire" />
                  {' '}
                  <span className="kodza-hero-focus__card">
                    <span className="kodza-hero-focus__mask">
                      <span className="kodza-hero-focus__rise kodza-hero-focus__label">03 — Entrance</span>
                    </span>
                    <span className="kodza-hero-focus__mask">
                      <span className="kodza-hero-focus__rise kodza-hero-focus__text">Set back 1.2 m. The rain stays outside.</span>
                    </span>
                  </span>
                </li>
              </ol>
            </div>
          </div>
          <div className="kodza-hero-focus__veil" />
        </div>
        <div className="kodza-hero-focus__inner">
          <p className="kodza-hero-focus__eyebrow">
            <span className="kodza-hero-focus__rise">Issue 07 — Concrete houses</span>
          </p>
          <div className="kodza-hero-focus__copy">
            <h1 className="kodza-hero-focus__title">
              <span className="kodza-hero-focus__line">
                <span className="kodza-hero-focus__rise">Look</span>
              </span>
              <span className="kodza-hero-focus__line">
                <span className="kodza-hero-focus__rise">closer.</span>
              </span>
            </h1>
            <p className="kodza-hero-focus__lead">A concrete house, rendered in code. Every detail has a reason. Scroll, and the lens finds it.</p>
          </div>
          <div className="kodza-hero-focus__foot">
            <a className="kodza-hero-focus__button" href="#">Read the issue<svg className="kodza-hero-focus__arrow" viewBox="0 0 16 16" aria-hidden="true"> <path d="M2.5 8h10.5M9 3.5 13.5 8 9 12.5" /> </svg></a>
            <p className="kodza-hero-focus__readout" aria-hidden="true">
              <span>focus</span>
              <span className="kodza-hero-focus__ring">
                <span className="kodza-hero-focus__mark" />
              </span>
              <span className="kodza-hero-focus__value">0.4 m</span>
            </p>
          </div>
        </div>
      </div>
    </section>
  );
}
```

## Code

```html
<section class="kodza-hero-focus">
  <div class="kodza-hero-focus__scene">
    <div class="kodza-hero-focus__media">
      <div class="kodza-hero-focus__lens">
        <div class="kodza-hero-focus__frame">
          <img class="kodza-hero-focus__img" src="https://kodza.dev/media/kz-overflow-2400.webp" alt="A two-storey concrete house, its upper floor cantilevered over the ground" width="2400" height="2400" />
          <ol class="kodza-hero-focus__notes">
            <li class="kodza-hero-focus__note" style="--x: 0.6921; --y: 0.4821" data-side="right">
              <span class="kodza-hero-focus__dot"></span><span class="kodza-hero-focus__wire"></span>
              <span class="kodza-hero-focus__card"><span class="kodza-hero-focus__mask"><span class="kodza-hero-focus__rise kodza-hero-focus__label">01 — Cantilever</span></span><span class="kodza-hero-focus__mask"><span class="kodza-hero-focus__rise kodza-hero-focus__text">3.8 m out. Nothing underneath.</span></span></span>
            </li>
            <li class="kodza-hero-focus__note" style="--x: 0.4979; --y: 0.3861" data-side="left">
              <span class="kodza-hero-focus__dot"></span><span class="kodza-hero-focus__wire"></span>
              <span class="kodza-hero-focus__card"><span class="kodza-hero-focus__mask"><span class="kodza-hero-focus__rise kodza-hero-focus__label">02 — Ribbon window</span></span><span class="kodza-hero-focus__mask"><span class="kodza-hero-focus__rise kodza-hero-focus__text">6.8 m of glass in one line. The view does the rest.</span></span></span>
            </li>
            <li class="kodza-hero-focus__note" style="--x: 0.3261; --y: 0.5895" data-side="left">
              <span class="kodza-hero-focus__dot"></span><span class="kodza-hero-focus__wire"></span>
              <span class="kodza-hero-focus__card"><span class="kodza-hero-focus__mask"><span class="kodza-hero-focus__rise kodza-hero-focus__label">03 — Entrance</span></span><span class="kodza-hero-focus__mask"><span class="kodza-hero-focus__rise kodza-hero-focus__text">Set back 1.2 m. The rain stays outside.</span></span></span>
            </li>
          </ol>
        </div>
      </div>
      <div class="kodza-hero-focus__veil"></div>
    </div>
    <div class="kodza-hero-focus__inner">
      <p class="kodza-hero-focus__eyebrow"><span class="kodza-hero-focus__rise">Issue 07 — Concrete houses</span></p>
      <div class="kodza-hero-focus__copy">
        <h1 class="kodza-hero-focus__title"><span class="kodza-hero-focus__line"><span class="kodza-hero-focus__rise">Look</span></span><span class="kodza-hero-focus__line"><span class="kodza-hero-focus__rise">closer.</span></span></h1>
        <p class="kodza-hero-focus__lead">A concrete house, rendered in code. Every detail has a reason. Scroll, and the lens finds it.</p>
      </div>
      <div class="kodza-hero-focus__foot">
        <a class="kodza-hero-focus__button" href="#">Read the issue<svg class="kodza-hero-focus__arrow" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 8h10.5M9 3.5 13.5 8 9 12.5" /></svg></a>
        <p class="kodza-hero-focus__readout" aria-hidden="true"><span>focus</span><span class="kodza-hero-focus__ring"><span class="kodza-hero-focus__mark"></span></span><span class="kodza-hero-focus__value">0.4 m</span></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 — Hero Focus
   A big title, sharp, over a soft picture. On scroll the scene pins and the focus moves: the
   picture sharpens and settles while the title passes the lens, blurs and leaves, and a readout
   counts the distance like a lens ring. Once the picture is sharp the notes come by themselves,
   one by one, on its details: a dot, a wire, a card. Each card finds a place that stays on screen.
   Without the script nothing pins and the notes stay hidden: the title over the soft picture.
   With reduced motion the scene pins, but the states swap at once.
   Everything is tuned with the --kz-hero-focus-* variables. */
.kodza-hero-focus {
  --kz-hero-focus-ink: #0a0a0a;
  --kz-hero-focus-paper: #f2f2f0;
  --kz-hero-focus-dim: rgba(10, 10, 10, 0.66);
  --kz-hero-focus-line: rgba(10, 10, 10, 0.12);
  --kz-hero-focus-card: #fff;
  --kz-hero-focus-veil: 242, 242, 240; /* the readability veil, rgb of the paper */
  --kz-hero-focus-ratio: 1; /* the picture's width / height */
  --kz-hero-focus-crop-x: 0.5; /* which part stays in view when the screen crops it, 0 to 1 */
  --kz-hero-focus-crop-y: 0.45;
  --kz-hero-focus-blur: 18px; /* how soft the picture is behind the words */
  --kz-hero-focus-gutter: clamp(16px, 4vw, 40px);
  --kz-hero-focus-pad-y: clamp(20px, 3vw, 40px);
  --kz-hero-focus-top: 0px; /* height of a fixed site header: the scene pins under it */
  --kz-hero-focus-distance: 200svh; /* how long the scene stays pinned */
  --kz-hero-focus-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
  --kz-hero-focus-ease: cubic-bezier(0.22, 1, 0.36, 1);
  container: kodza-hero-focus / inline-size;
  position: relative;
  box-sizing: border-box;
  width: 100%;
  color: var(--kz-hero-focus-ink);
}

.kodza-hero-focus__scene {
  position: relative;
  overflow: hidden;
  overflow: clip;
  height: calc(100svh - var(--kz-hero-focus-top));
}

/* The picture covers the scene, whatever its shape: the notes ride on it in its own % */
.kodza-hero-focus__media {
  position: absolute;
  inset: 0;
  container-type: size;
  overflow: hidden;
  overflow: clip;
}

.kodza-hero-focus__lens {
  position: absolute;
  inset: 0;
}

.kodza-hero-focus__frame {
  position: absolute;
  left: calc((100cqw - max(100cqw, 100cqh * var(--kz-hero-focus-ratio))) * var(--kz-hero-focus-crop-x));
  top: calc((100cqh - max(100cqw / var(--kz-hero-focus-ratio), 100cqh)) * var(--kz-hero-focus-crop-y));
  width: max(100cqw, 100cqh * var(--kz-hero-focus-ratio));
  aspect-ratio: var(--kz-hero-focus-ratio);
}

.kodza-hero-focus__img {
  display: block;
  width: 100%;
  max-width: none;
  height: 100%;
  object-fit: cover;
  filter: blur(var(--kz-hero-focus-blur));
}

.kodza-hero-focus__veil {
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, rgba(var(--kz-hero-focus-veil), 0.9), rgba(var(--kz-hero-focus-veil), 0.62) 55%, rgba(var(--kz-hero-focus-veil), 0.4));
  pointer-events: none;
}

/* The notes: a dot on the detail, a wire, a card */
.kodza-hero-focus__notes {
  margin: 0;
  padding: 0;
  list-style: none;
}

.kodza-hero-focus:not(.is-ready) .kodza-hero-focus__notes {
  display: none;
}

.kodza-hero-focus__note {
  position: absolute;
  left: calc(var(--x) * 100%);
  top: calc(var(--y) * 100%);
  width: 0;
  height: 0;
  margin: 0;
  padding: 0;
}

.kodza-hero-focus__dot {
  position: absolute;
  left: -6px;
  top: -6px;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--kz-hero-focus-ink);
  box-shadow: 0 0 0 3px var(--kz-hero-focus-card), 0 0 0 4px var(--kz-hero-focus-line);
}

.kodza-hero-focus__wire {
  position: absolute;
  top: -0.5px;
  left: 10px;
  width: 40px;
  height: 1px;
  background: var(--kz-hero-focus-ink);
  box-shadow: 0 0 0 1px var(--kz-hero-focus-card);
  transform-origin: left center;
}

.kodza-hero-focus__card {
  position: absolute;
  left: 50px;
  top: 0;
  box-sizing: border-box;
  width: 236px;
  padding: 13px 15px 14px;
  border: 1px solid var(--kz-hero-focus-line);
  border-radius: 12px;
  background: var(--kz-hero-focus-card);
  box-shadow: 0 18px 40px -22px rgba(10, 10, 10, 0.45);
  translate: 0 -50%;
}

/* Where the script placed the card: beside its dot (right, the default, or left), under or above it */
.kodza-hero-focus__note[data-place="left"] .kodza-hero-focus__wire {
  left: auto;
  right: 10px;
  transform-origin: right center;
}

.kodza-hero-focus__note[data-place="left"] .kodza-hero-focus__card {
  left: auto;
  right: 50px;
}

.kodza-hero-focus__note[data-place="below"] .kodza-hero-focus__wire {
  left: -0.5px;
  top: 10px;
  width: 1px;
  height: 26px;
  transform-origin: center top;
}

.kodza-hero-focus__note[data-place="below"] .kodza-hero-focus__card {
  left: var(--shift, 0px);
  top: 36px;
  translate: none;
}

.kodza-hero-focus__note[data-place="above"] .kodza-hero-focus__wire {
  left: -0.5px;
  top: auto;
  bottom: 10px;
  width: 1px;
  height: 26px;
  transform-origin: center bottom;
}

.kodza-hero-focus__note[data-place="above"] .kodza-hero-focus__card {
  left: var(--shift, 0px);
  top: auto;
  bottom: 36px;
  translate: none;
}

.kodza-hero-focus__mask {
  display: block;
  overflow: hidden;
}

.kodza-hero-focus__rise {
  display: block;
}

.kodza-hero-focus__label {
  margin-bottom: 5px;
  font-family: var(--kz-hero-focus-mono);
  font-size: 12px;
  color: var(--kz-hero-focus-dim);
}

.kodza-hero-focus__text {
  font-size: 14.5px;
  line-height: 1.45;
  text-wrap: pretty;
}

/* A scene too low for the full cards: the title alone on screen, the text kept for screen readers */
.kodza-hero-focus.is-brief .kodza-hero-focus__card {
  width: max-content;
}

.kodza-hero-focus.is-brief .kodza-hero-focus__label {
  margin-bottom: 0;
}

.kodza-hero-focus.is-brief .kodza-hero-focus__mask + .kodza-hero-focus__mask {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The words, over the picture */
.kodza-hero-focus__inner {
  position: relative;
  z-index: 1;
  box-sizing: border-box;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
  height: 100%;
  padding: var(--kz-hero-focus-pad-y) var(--kz-hero-focus-gutter);
  pointer-events: none;
}

.kodza-hero-focus__inner a {
  pointer-events: auto;
}

.kodza-hero-focus__eyebrow {
  margin: 0;
  overflow: hidden;
  font-family: var(--kz-hero-focus-mono);
  font-size: 12.5px;
  font-weight: 500;
  color: var(--kz-hero-focus-dim);
}

.kodza-hero-focus__copy {
  align-self: center;
  max-width: 1240px;
  transform-origin: 0% 50%;
}

.kodza-hero-focus__title {
  margin: 0 0 clamp(18px, 2.4vw, 30px);
  font-size: clamp(64px, 12.5vw, 196px);
  font-weight: 600;
  line-height: 0.9;
  letter-spacing: -0.06em;
}

.kodza-hero-focus__line {
  display: block;
  overflow: hidden;
  padding-bottom: 0.1em;
  margin-bottom: -0.1em;
}

.kodza-hero-focus__lead {
  max-width: 34ch;
  margin: 0;
  font-size: clamp(17px, 1.45vw, 20px);
  line-height: 1.55;
  color: var(--kz-hero-focus-dim);
  text-wrap: pretty;
}

.kodza-hero-focus__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
}

.kodza-hero-focus__button {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  height: 52px;
  padding: 0 26px;
  border-radius: 999px;
  background: var(--kz-hero-focus-ink);
  color: var(--kz-hero-focus-paper);
  font-size: 15px;
  font-weight: 500;
  text-decoration: none;
  white-space: nowrap;
}

.kodza-hero-focus__button:focus-visible {
  outline: 2px solid var(--kz-hero-focus-ink);
  outline-offset: 3px;
}

.kodza-hero-focus__arrow {
  width: 16px;
  height: 16px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform 0.3s var(--kz-hero-focus-ease);
}

@media (hover: hover) {
  .kodza-hero-focus__button:hover .kodza-hero-focus__arrow {
    transform: translateX(3px);
  }
}

/* The focus readout, a lens ring in mono: the distance follows the picture's focus */
.kodza-hero-focus__readout {
  display: flex;
  align-items: center;
  gap: 12px;
  height: 40px;
  margin: 0;
  padding: 0 16px;
  border-radius: 999px;
  background: rgba(var(--kz-hero-focus-veil), 0.72);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  font-family: var(--kz-hero-focus-mono);
  font-size: 12.5px;
  font-variant-numeric: tabular-nums;
  color: var(--kz-hero-focus-ink);
  white-space: nowrap;
}

.kodza-hero-focus__ring {
  position: relative;
  width: 120px;
  height: 9px;
  background:
    linear-gradient(var(--kz-hero-focus-ink), var(--kz-hero-focus-ink)) 0 50% / 100% 1px no-repeat,
    repeating-linear-gradient(90deg, var(--kz-hero-focus-line) 0 1px, transparent 1px 12px);
}

.kodza-hero-focus__mark {
  position: absolute;
  left: 0;
  top: -3px;
  width: 2px;
  height: 15px;
  margin-left: -1px;
  border-radius: 1px;
  background: var(--kz-hero-focus-ink);
}

.kodza-hero-focus__value {
  min-width: 5.5ch;
  text-align: right;
}

/* A phone: the readout keeps its number, the cards get narrower */
@container kodza-hero-focus (max-width: 640px) {
  .kodza-hero-focus__ring {
    display: none;
  }

  .kodza-hero-focus__card {
    width: 196px;
  }

  .kodza-hero-focus__text {
    font-size: 13.5px;
  }
}

/* With the script: a tall track, the scene pinned inside it */
.kodza-hero-focus.is-ready {
  height: calc(100svh - var(--kz-hero-focus-top) + var(--kz-hero-focus-distance));
}

.kodza-hero-focus.is-ready .kodza-hero-focus__scene {
  position: sticky;
  top: var(--kz-hero-focus-top);
}

@media (prefers-reduced-motion: no-preference) {
  /* Waiting for its entrance: the script sets is-shown and plays the motion in the same frame */
  .kodza-hero-focus.is-ready:not(.is-shown) .kodza-hero-focus__eyebrow .kodza-hero-focus__rise,
  .kodza-hero-focus.is-ready:not(.is-shown) .kodza-hero-focus__title .kodza-hero-focus__rise {
    transform: translateY(105%);
  }

  .kodza-hero-focus.is-ready:not(.is-shown) .kodza-hero-focus__lead,
  .kodza-hero-focus.is-ready:not(.is-shown) .kodza-hero-focus__foot {
    opacity: 0;
    transform: translateY(20px);
  }

  .kodza-hero-focus.is-ready:not(.is-shown) .kodza-hero-focus__media {
    clip-path: inset(0 0 100% 0);
  }

  /* The notes wait for the scroll to call them */
  .kodza-hero-focus.is-ready:not(.is-live) .kodza-hero-focus__notes {
    visibility: hidden;
  }
}

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

  /* Nothing moves: past the focus pull the picture is sharp and the words gone, then the notes are there */
  .kodza-hero-focus.is-ready .kodza-hero-focus__note:not(.is-open) {
    visibility: hidden;
  }

  .kodza-hero-focus.is-sharp .kodza-hero-focus__img {
    filter: none;
  }

  .kodza-hero-focus.is-sharp .kodza-hero-focus__veil {
    opacity: 0;
  }

  .kodza-hero-focus.is-sharp .kodza-hero-focus__copy {
    visibility: hidden;
    opacity: 0;
  }

  .kodza-hero-focus.is-sharp .kodza-hero-focus__mark {
    transform: translateX(120px);
  }
}

/* kodza-guard 1bebfb541252 — 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-hero-focus__img):not(#_),
:where(.kodza-hero-focus__notes):not(#_),
:where(.kodza-hero-focus__note):not(#_),
:where(.kodza-hero-focus__eyebrow):not(#_),
:where(.kodza-hero-focus__title):not(#_),
:where(.kodza-hero-focus__lead):not(#_),
:where(.kodza-hero-focus__readout):not(#_) {
  transition-duration: revert;
}

:where(.kodza-hero-focus__button):not(#_) {
  outline: revert;
  outline-offset: revert;
  transition-duration: revert;
}

:where(.kodza-hero-focus__notes):not(#_):where(:not([hidden])) {
  display: revert;
}

:where(.kodza-hero-focus__notes):not(#_):where([hidden]) {
  display: none;
}

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

.kodza-hero-focus__scene:not(#_),
.kodza-hero-focus__media:not(#_),
.kodza-hero-focus__lens:not(#_),
.kodza-hero-focus__frame:not(#_),
.kodza-hero-focus__dot:not(#_),
.kodza-hero-focus__wire:not(#_),
.kodza-hero-focus__mask:not(#_),
.kodza-hero-focus__rise:not(#_),
.kodza-hero-focus__veil:not(#_),
.kodza-hero-focus__copy:not(#_),
.kodza-hero-focus__line:not(#_),
.kodza-hero-focus__foot:not(#_),
.kodza-hero-focus__ring:not(#_),
.kodza-hero-focus__mark:not(#_),
.kodza-hero-focus__value:not(#_),
.kodza-hero-focus__readout span:where(:not([class*="kodza-"])):not(#_) {
  box-sizing: revert;
}

.kodza-hero-focus__img: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-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-hero-focus__notes:not(#_) {
  border: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: 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-hero-focus__note:not(#_) {
  border: 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-hero-focus__eyebrow:not(#_) {
  border: 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;
  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-hero-focus__title:not(#_) {
  border: 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-style: 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-hero-focus__lead:not(#_) {
  border: 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-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;
  transition-property: revert;
  transition-timing-function: revert;
  transition-delay: revert;
}

.kodza-hero-focus__button:not(#_) {
  border: revert;
  margin: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  box-shadow: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-family: 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-hero-focus__arrow: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;
}

.kodza-hero-focus__readout:not(#_) {
  border: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: 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-hero-focus__note:not(#_):where(:not([hidden])),
.kodza-hero-focus__eyebrow:not(#_):where(:not([hidden])),
.kodza-hero-focus__title:not(#_):where(:not([hidden])),
.kodza-hero-focus__lead:not(#_):where(:not([hidden])),
.kodza-hero-focus__arrow:not(#_):where(:not([hidden])) {
  display: revert;
}

.kodza-hero-focus__note:not(#_):where([hidden]),
.kodza-hero-focus__eyebrow:not(#_):where([hidden]),
.kodza-hero-focus__title:not(#_):where([hidden]),
.kodza-hero-focus__lead:not(#_):where([hidden]),
.kodza-hero-focus__arrow: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-hero-focus__img:not(#_) {
  display: block;
  max-width: none;
}

.kodza-hero-focus__notes:not(#_) {
  margin: 0;
  padding: 0;
  list-style: none;
}

.kodza-hero-focus:not(.is-ready) .kodza-hero-focus__notes:not(#_) {
  display: none;
}

.kodza-hero-focus__note:not(#_) {
  position: absolute;
  margin: 0;
  padding: 0;
}

.kodza-hero-focus__eyebrow:not(#_) {
  margin: 0;
  font-family: var(--kz-hero-focus-mono);
  font-size: 12.5px;
  font-weight: 500;
  color: var(--kz-hero-focus-dim);
}

.kodza-hero-focus__title:not(#_) {
  margin: 0 0 clamp(18px, 2.4vw, 30px);
  font-size: clamp(64px, 12.5vw, 196px);
  font-weight: 600;
  line-height: 0.9;
  letter-spacing: -0.06em;
}

.kodza-hero-focus__lead:not(#_) {
  max-width: 34ch;
  margin: 0;
  font-size: clamp(17px, 1.45vw, 20px);
  line-height: 1.55;
  color: var(--kz-hero-focus-dim);
}

.kodza-hero-focus__button:not(#_) {
  display: inline-flex;
  padding: 0 26px;
  border-radius: 999px;
  background: var(--kz-hero-focus-ink);
  color: var(--kz-hero-focus-paper);
  font-size: 15px;
  font-weight: 500;
  text-decoration: none;
}

.kodza-hero-focus__button:focus-visible:not(#_) {
  outline: 2px solid var(--kz-hero-focus-ink);
  outline-offset: 3px;
}

.kodza-hero-focus__arrow:not(#_) {
  transition: transform 0.3s var(--kz-hero-focus-ease);
}

.kodza-hero-focus__readout:not(#_) {
  display: flex;
  margin: 0;
  padding: 0 16px;
  border-radius: 999px;
  background: rgba(var(--kz-hero-focus-veil), 0.72);
  font-family: var(--kz-hero-focus-mono);
  font-size: 12.5px;
  color: var(--kz-hero-focus-ink);
}

@media (prefers-reduced-motion: reduce) {
  .kodza-hero-focus *:where(img, ol, li, p, h1, a, 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 — Hero Focus
// Plays the entrance once the section is in view (the label and title lines rise out of their
// masks, the picture opens from the top edge down while it settles), then hands over to the
// scroll: the scene pins and the focus moves from the words to the picture. The picture
// sharpens, the title passes the lens, blurs and leaves, and the readout counts the distance.
// Once the picture is sharp the notes come by themselves, one after the other: a dot on the
// detail, a wire, a card, each drawn at its own pace. A scroll that runs ahead of them hurries
// them, so they are drawn before the scene leaves. Scrolling back up closes them, the last one
// first, and plays the focus pull in reverse.
// "kodza:replay" plays the entrance again.
(() => {
  const SELECTOR = '.kodza-hero-focus';
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const GLIDE = 'cubic-bezier(0.6, 0, 0.1, 1)'; // slow start, long settle
  const SOFT = 'cubic-bezier(0.25, 1, 0.5, 1)';
  const TRAVEL = 'cubic-bezier(0.65, 0, 0.35, 1)'; // the big moves on the scroll
  const RISE = 'cubic-bezier(0.22, 1, 0.36, 1)';
  const NEAR = 0.4; // the readout, in metres: on the words, then on the picture
  const FAR = 6;
  const FOCUS = [0.045, 0.6]; // the focus pull, in progress of the pinned scene
  const NOTES_AT = 0.56; // the picture is sharp: the notes come by themselves; the rest holds it
  const NOTES_BY = 0.92; // a fast scroll hurries them: all drawn by here, before the scene unpins
  const NOTE_GAP = 450; // ms between two notes on the way in
  const CLOSE_GAP = 140; // ms between two notes on the way out, the last one first

  const decoded = (root) => Promise.all([...root.querySelectorAll('img')].map((img) => img.decode().catch(() => {})));

  // ---------- Scroll engine (begin) ----------
  // The same block in every hero that plays on scroll: copy it as is, write only the timeline.
  // `root` is a tall track and `scene` sticks inside it (position: sticky, in the CSS); the
  // progress runs from 0, the scene just pinned, to 1, about to unpin. `timeline()` returns
  // tracks, [element, keyframes, from, to] with from and to in progress; each becomes a paused
  // Web Animation whose time follows the scroll, smoothed by a requestAnimationFrame loop that
  // sleeps once it has caught up. The tracks are built again when `scene` or an element of
  // `watch` changes size, so `timeline()` measures there.
  function scrub(root, scene, signal, timeline, watch = []) {
    const SPAN = 1000;
    let tracks = [];
    let live = false;
    let now = 0;
    let target = 0;
    let frame = 0;
    let last = 0;
    const geometry = () => {
      const box = root.getBoundingClientRect();
      return {
        top: box.top,
        scale: box.height / root.offsetHeight || 1, // a parent scaled by transform
        run: root.offsetHeight - scene.offsetHeight,
        stick: parseFloat(getComputedStyle(scene).top) || 0,
      };
    };
    const progress = () => {
      const { top, scale, run, stick } = geometry();
      return run > 0 ? Math.min(1, Math.max(0, (stick - top / scale) / run)) : 0;
    };
    const seek = () => tracks.forEach((a) => { a.currentTime = now * SPAN; });
    const tick = (time) => {
      const dt = last ? Math.min(time - last, 64) : 16;
      last = time;
      now += (target - now) * (1 - Math.exp(-dt / 70));
      if (Math.abs(target - now) < 0.0004) now = target;
      seek();
      frame = now === target ? 0 : requestAnimationFrame(tick);
      if (!frame) last = 0;
    };
    const update = () => {
      if (!live) return;
      target = progress();
      if (!frame) frame = requestAnimationFrame(tick);
    };
    const clear = () => {
      tracks.forEach((a) => a.cancel());
      tracks = [];
    };
    const build = () => {
      clear();
      if (!live) return;
      tracks = timeline().filter(([el]) => el).map(([el, keyframes, from, to]) => {
        const a = el.animate(keyframes, { duration: (to - from) * SPAN, delay: from * SPAN, endDelay: (1 - to) * SPAN, fill: 'both' });
        a.pause();
        return a;
      });
      seek();
      update();
    };
    const stop = () => {
      live = false;
      cancelAnimationFrame(frame);
      frame = last = now = 0;
      clear();
    };
    // Scrolls the page so the progress is at least `p` (a focused control must show)
    const reveal = (p) => {
      const { top, scale, run, stick } = geometry();
      if (run > 0 && progress() < p) window.scrollBy(0, top - (stick - p * run) * scale);
    };

    document.addEventListener('scroll', update, { passive: true, capture: true, signal });
    window.addEventListener('resize', update, { passive: true, signal });
    let resized = 0;
    const ro = new ResizeObserver(() => {
      cancelAnimationFrame(resized);
      resized = requestAnimationFrame(build);
    });
    [scene, ...watch].forEach((el) => el && ro.observe(el));
    signal.addEventListener('abort', () => {
      ro.disconnect();
      cancelAnimationFrame(resized);
      stop();
    });
    return {
      start() {
        live = true;
        build();
      },
      stop,
      progress,
      reveal,
    };
  }
  // ---------- Scroll engine (end) ----------

  // 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 q = (s) => root.querySelector(s);
    const scene = q('.kodza-hero-focus__scene');
    const img = q('.kodza-hero-focus__img');
    const notes = [...root.querySelectorAll('.kodza-hero-focus__note')];
    const value = q('.kodza-hero-focus__value');
    const readout = (distance) => `${distance.toFixed(1)} m`;

    // Each card takes the first place that stays on screen, clear of the label, the buttons and
    // the cards already placed: its own side, the other side, under its dot, above it
    const place = () => {
      const W = scene.clientWidth;
      const H = scene.clientHeight;
      const vars = getComputedStyle(root);
      const ratio = parseFloat(vars.getPropertyValue('--kz-hero-focus-ratio')) || 1;
      const cropX = parseFloat(vars.getPropertyValue('--kz-hero-focus-crop-x')) || 0;
      const cropY = parseFloat(vars.getPropertyValue('--kz-hero-focus-crop-y')) || 0;
      const inner = getComputedStyle(q('.kodza-hero-focus__inner'));
      const g = parseFloat(inner.paddingLeft) || 16;
      const top = parseFloat(inner.paddingTop) + 28;
      const bottom = H - parseFloat(inner.paddingBottom) - q('.kodza-hero-focus__foot').offsetHeight - 12;
      const fw = Math.max(W, H * ratio);
      const fh = fw / ratio;
      // Every way each card can sit on screen, its own side first
      const measure = () => notes.map((note) => {
        const s = getComputedStyle(note);
        const px = (W - fw) * cropX + parseFloat(s.getPropertyValue('--x')) * fw;
        const py = (H - fh) * cropY + parseFloat(s.getPropertyValue('--y')) * fh;
        const card = note.querySelector('.kodza-hero-focus__card');
        const cw = card.offsetWidth;
        const ch = card.offsetHeight;
        const cx = Math.min(W - g - cw, Math.max(g, px - cw / 2));
        note.style.setProperty('--shift', `${cx - px}px`);
        const rects = {
          right: [px + 50, py - ch / 2, cw, ch],
          left: [px - 50 - cw, py - ch / 2, cw, ch],
          below: [cx, py + 36, cw, ch],
          above: [cx, py - 36 - ch, cw, ch],
        };
        const side = note.dataset.side || 'right';
        const order = [side, side === 'right' ? 'left' : 'right', 'below', 'above'];
        const fits = order.filter((k) => {
          const [x, y] = rects[k];
          return x >= g && x + cw <= W - g && y >= top && y + ch <= bottom;
        });
        return (fits.length ? fits : [side]).map((k) => [k, rects[k]]);
      });
      // The first set where no two cards touch (three notes: a handful of tries)
      const clear = ([x, y, w, h], [a, b, c, d]) => x + w + 8 < a || a + c + 8 < x || y + h + 8 < b || b + d + 8 < y;
      const solve = (options, i = 0, taken = []) => {
        if (i === options.length) return taken;
        for (const o of options[i]) {
          if (taken.every((t) => clear(t[1], o[1]))) {
            const found = solve(options, i + 1, [...taken, o]);
            if (found) return found;
          }
        }
        return null;
      };
      // A scene too low for the full cards (a short frame, a phone on its side): each keeps its
      // title and the text stays for screen readers
      root.classList.remove('is-brief');
      let options = measure();
      let picks = solve(options);
      if (!picks) {
        root.classList.add('is-brief');
        options = measure();
        picks = solve(options) || options.map((o) => o[0]);
      }
      notes.forEach((note, i) => { note.dataset.place = picks[i][0]; });
    };

    // Reduced motion: the scene pins all the same, but each state comes at once when the scroll
    // reaches it (the picture sharp past the middle of the focus pull, then the notes)
    if (reduceMotion) {
      root.classList.add('is-shown');
      const pin = scrub(root, scene, signal, () => {
        place();
        return [];
      });
      const swap = () => {
        const p = pin.progress();
        const sharp = p >= (FOCUS[0] + FOCUS[1]) / 2;
        root.classList.toggle('is-sharp', sharp);
        value.textContent = readout(sharp ? FAR : NEAR);
        notes.forEach((note) => note.classList.toggle('is-open', p >= NOTES_AT));
      };
      pin.start();
      swap();
      document.addEventListener('scroll', swap, { passive: true, capture: true, signal });
      return;
    }

    // The notes play in time, not with the wheel: each one is a set of paused animations (dot,
    // wire, card, words) moved frame by frame, forward once the picture is sharp and backward when
    // it blurs again. Each part stops at its own end, so on the way out they all close at once.
    // The scroll can only move a note ahead, never pull it back
    const open = notes.map(() => false);
    const timers = [];
    const rates = notes.map(() => 0);
    let plays = notes.map(() => []);
    let lengths = notes.map(() => 0);
    let lastStart = 0;
    const end = (a) => a.effect.getComputedTiming().endTime;
    let ticking = 0;
    let before = 0;
    const tick = (time) => {
      const dt = before ? Math.min(time - before, 64) : 16;
      before = time;
      let moving = false;
      rates.forEach((rate, i) => {
        if (!rate) return;
        let still = false;
        plays[i].forEach((a) => {
          const t = Math.min(end(a), Math.max(0, a.currentTime + dt * rate));
          a.currentTime = t;
          if (rate > 0 ? t < end(a) : t > 0) still = true;
        });
        if (still) moving = true;
        else rates[i] = 0;
      });
      ticking = moving ? requestAnimationFrame(tick) : 0;
      if (!ticking) before = 0;
    };
    const move = (i, rate) => {
      rates[i] = rate;
      if (!ticking) ticking = requestAnimationFrame(tick);
    };
    // Built again when the scene changes size: each part keeps where it was
    const draw = () => {
      const kept = plays.map((set) => set.map((a) => a.currentTime || 0));
      plays.flat().forEach((a) => a.cancel());
      plays = notes.map((note, i) => {
        const where = note.dataset.place;
        const from = { right: 'inset(0 100% 0 0 round 12px)', left: 'inset(0 0 0 100% round 12px)', below: 'inset(0 0 100% 0 round 12px)', above: 'inset(100% 0 0 0 round 12px)' }[where];
        const upright = where === 'below' || where === 'above';
        let j = 0;
        const step = (el, keyframes, delay, duration, easing) => {
          const a = el.animate(keyframes, { duration, delay, easing, fill: 'both' });
          a.pause();
          a.currentTime = kept[i]?.[j++] || 0;
          return a;
        };
        return [
          step(note.querySelector('.kodza-hero-focus__dot'), [{ transform: 'scale(0)' }, { transform: 'scale(1)' }], 0, 420, RISE),
          step(note.querySelector('.kodza-hero-focus__wire'), [{ transform: upright ? 'scaleY(0)' : 'scaleX(0)' }, { transform: 'none' }], 180, 540, TRAVEL),
          step(note.querySelector('.kodza-hero-focus__card'), [{ clipPath: from }, { clipPath: 'inset(0 0 0 0 round 12px)' }], 400, 780, TRAVEL),
          ...[...note.querySelectorAll('.kodza-hero-focus__card .kodza-hero-focus__rise')].map((el, j) => (
            step(el, [{ transform: 'translateY(105%)' }, { transform: 'none' }], 560 + j * 90, 860, RISE)
          )),
        ];
      });
      lengths = plays.map((set) => Math.max(...set.map(end)));
    };
    const run = (i, on) => {
      clearTimeout(timers[i]);
      open[i] = on;
      if (!on) {
        lastStart = 0;
        timers[i] = setTimeout(() => move(i, -1.8), (notes.length - 1 - i) * CLOSE_GAP); // they leave faster than they came
        return;
      }
      const now = performance.now();
      const wait = Math.max(0, lastStart + NOTE_GAP - now);
      lastStart = now + wait;
      timers[i] = setTimeout(() => move(i, 1), wait);
    };
    // The scroll past the picture, in progress: where the notes must be at least, played in a row
    // from NOTES_AT to NOTES_BY. A slow scroll leaves them their own pace, a fast one hurries them
    const hurryNotes = (p) => {
      const t = ((p - NOTES_AT) / (NOTES_BY - NOTES_AT)) * ((notes.length - 1) * NOTE_GAP + lengths[notes.length - 1]);
      plays.forEach((set, i) => {
        const floor = t - i * NOTE_GAP;
        if (!open[i] || floor <= 0) return;
        let behind = false;
        set.forEach((a) => {
          if (a.currentTime < Math.min(end(a), floor)) a.currentTime = Math.min(end(a), floor);
          if (a.currentTime < end(a)) behind = true;
        });
        if (rates[i] > 0 || (!behind && !rates[i])) return;
        clearTimeout(timers[i]);
        if (behind) move(i, 1);
        else rates[i] = 0;
      });
    };
    const quiet = () => {
      timers.forEach(clearTimeout);
      plays.flat().forEach((a) => a.cancel());
      plays = notes.map(() => []);
      rates.fill(0);
      cancelAnimationFrame(ticking);
      ticking = before = 0;
      open.fill(false);
      lastStart = 0;
    };

    // The scroll part, in progress: the focus pull, then the picture holds for the notes
    const timeline = () => {
      place();
      draw();
      const [a, b] = FOCUS;
      return [
        // The rack focus: the picture sharpens and settles, the words pass the lens and blur
        [img, [{ filter: 'blur(var(--kz-hero-focus-blur))', easing: TRAVEL }, { filter: 'blur(0px)' }], a, b],
        [q('.kodza-hero-focus__frame'), [{ transform: 'scale(1.08)', easing: TRAVEL }, { transform: 'scale(1)' }], a, b],
        [q('.kodza-hero-focus__veil'), [{ opacity: 1, easing: TRAVEL }, { opacity: 0 }], a, b - 0.04],
        [q('.kodza-hero-focus__copy'), [
          { filter: 'blur(0px)', transform: 'scale(1)', opacity: 1, easing: 'cubic-bezier(0.5, 0, 0.75, 0)' },
          { filter: 'blur(14px)', transform: 'scale(1.1)', opacity: 0 },
        ], a, b - 0.08],
        [q('.kodza-hero-focus__mark'), [{ transform: 'translateX(0px)', easing: TRAVEL }, { transform: 'translateX(120px)' }], a, b],
      ];
    };
    const pin = scrub(root, scene, signal, timeline);
    const sync = () => {
      const due = pin.progress() >= NOTES_AT;
      notes.forEach((note, i) => {
        if (due !== open[i]) run(i, due);
      });
    };

    // The readout follows the picture's focus, as the lens ring would, and sleeps once it rests;
    // the notes take the same smoothed progress, so a hurried one is still drawn, not cut
    const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
    let shown = '';
    let seen = -1;
    let raf = 0;
    const read = () => {
      const a = img.getAnimations()[0];
      const time = a ? a.currentTime : 0;
      const k = Math.min(1, Math.max(0, (time / 1000 - FOCUS[0]) / (FOCUS[1] - FOCUS[0]))); // the engine's span is 1000
      const text = readout(NEAR + (FAR - NEAR) * ease(k));
      if (text !== shown) value.textContent = shown = text;
      hurryNotes(time / 1000);
      raf = time !== seen ? requestAnimationFrame(read) : 0;
      seen = time;
    };
    const wake = () => {
      if (!raf) raf = requestAnimationFrame(read);
    };

    let running = [];
    const stop = () => {
      running.forEach((a) => a.cancel());
      running = [];
    };
    const animate = (el, keyframes, options) => {
      if (el) running.push(el.animate(keyframes, { easing: GLIDE, fill: 'backwards', ...options }));
    };
    const rise = (el, delay, duration) => animate(el, [{ transform: 'translateY(105%)' }, { transform: 'none' }], { duration, delay });
    // Scrolling during the entrance hurries it, so the scroll takes over without a wait
    const hurry = () => {
      if (running.length && !root.classList.contains('is-live') && pin.progress() > 0) running.forEach((a) => a.updatePlaybackRate(4));
    };

    // The entrance of Hero Split: label and lines rise out of their masks, the picture opens
    // from the top edge down while it settles
    const play = async () => {
      await decoded(root);
      if (signal.aborted) return;
      stop();
      pin.stop();
      quiet();
      root.classList.remove('is-live');
      root.classList.add('is-shown');
      rise(q('.kodza-hero-focus__eyebrow .kodza-hero-focus__rise'), 0, 1000);
      root.querySelectorAll('.kodza-hero-focus__title .kodza-hero-focus__rise').forEach((el, i) => rise(el, 110 + i * 125, 1170));
      animate(q('.kodza-hero-focus__media'), [{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0% 0)' }], { duration: 1300, delay: 60 });
      animate(q('.kodza-hero-focus__lens'), [{ transform: 'scale(1.2)' }, { transform: 'none' }], { duration: 1800, delay: 60, easing: SOFT });
      [q('.kodza-hero-focus__lead'), q('.kodza-hero-focus__foot')].forEach((el, i) => {
        animate(el, [{ opacity: 0, transform: 'translateY(20px)' }, { opacity: 1, transform: 'none' }], { duration: 900, delay: 700 + i * 110, easing: SOFT });
      });
      hurry();
      const entrance = running;
      Promise.all(entrance.map((a) => a.finished)).then(() => {
        if (entrance !== running || signal.aborted) return;
        root.classList.add('is-live');
        pin.start();
        sync();
        wake();
      }, () => {});
    };

    root.addEventListener('kodza:replay', play, { signal });
    document.addEventListener('scroll', () => {
      hurry();
      if (!root.classList.contains('is-live')) return;
      sync();
      wake();
    }, { passive: true, capture: true, signal });
    signal.addEventListener('abort', () => {
      stop();
      quiet();
      cancelAnimationFrame(raf);
    });
    const io = new IntersectionObserver((entries) => {
      if (!entries.some((entry) => entry.isIntersecting)) return;
      io.disconnect();
      play();
    }, { rootMargin: '0px 0px -15% 0px' });
    signal.addEventListener('abort', () => io.disconnect());
    Promise.all([document.fonts?.ready, decoded(root)]).then(() => {
      if (!signal.aborted) io.observe(root);
    });
  }

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