# Page Lift

> Between two real pages, the one you leave recedes as the next rises over it on a rounded edge. The header stays, back plays it in reverse. Chrome, Edge, Safari; Firefox loads plainly for now. No GSAP.

Preloaders & transitions · page transition, view transitions, multi-page, case study, portfolio, swup · HTML, CSS, vanilla JS · no dependencies · updated 2026-10-21
Demo: https://kodza.dev/components/page-lift/

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-page-lift.css`](https://kodza.dev/registry/page-lift/kodza-page-lift.css) · 16.0 KB
- [`kodza-page-lift.js`](https://kodza.dev/registry/page-lift/kodza-page-lift.js) · 20.8 KB
- [`kodza-page-lift.html`](https://kodza.dev/registry/page-lift/kodza-page-lift.html) · 1.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-page-lift.css https://kodza.dev/registry/page-lift/kodza-page-lift.css
curl -fsSL --create-dirs -o kodza/kodza-page-lift.js https://kodza.dev/registry/page-lift/kodza-page-lift.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-page-lift]`
- Variables (set them on the root element itself: it declares its own defaults):
  - `--kz-page-lift-duration: 0.8s` — the lift, both ways
  - `--kz-page-lift-ease: cubic-bezier(0.76, 0, 0.24, 1)`
  - `--kz-page-lift-depth: 0.92` — how far the page you leave recedes
  - `--kz-page-lift-veil: 0.55` — and how dark it gets
  - `--kz-page-lift-rise: 16%` — the next page starts this far down, under its own top edge
  - `--kz-page-lift-radius: 20px` — its top corners, flat once it lands
  - `--kz-page-lift-backdrop: #0a0a0a` — behind the page that recedes
  - `--kz-page-lift-enter-delay: 0.36s` — the arrival starts while the page is still rising
  - `--kz-page-lift-enter-ease: cubic-bezier(0.2, 0.8, 0.1, 1)`
  - `--kz-page-lift-reveal-ease: cubic-bezier(0.65, 0, 0.35, 1)` — the image uncovering: gentle start, no flash
  - `--kz-page-lift-bg: #f2f2f0`
  - `--kz-page-lift-fg: #0a0a0a`
  - `--kz-page-lift-muted: rgba(10, 10, 10, 0.62)`
  - `--kz-page-lift-line: rgba(10, 10, 10, 0.12)`
  - `--kz-page-lift-image-radius: 16px`
  - `--kz-page-lift-side: 24px`
- Attributes:
  - `data-enter` (e.g. `label`, `title`, `image`)
  - `data-kodza-page-lift`
  - `data-page-back`
- Events: `kodza:page-engine`, `kodza:page-transition` (bubbling, from the root)
- 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-page-lift {
    --kz-page-lift-bg: #0a0a0a;
    --kz-page-lift-fg: #f2f2f0;
    --kz-page-lift-muted: rgba(242, 242, 240, 0.62);
    --kz-page-lift-line: rgba(242, 242, 240, 0.14);
    --kz-page-lift-backdrop: #222;
  }
}
```

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

Its images have dark versions next to them (`kz-steps-2400.webp` → `kz-steps-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-page-lift.css` next to the page (the Files commands as they are), linked in `<head>`: `<link rel="stylesheet" href="kodza/kodza-page-lift.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-page-lift.js`, loaded with `<script src="kodza/kodza-page-lift.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-page-lift.css' -e 'd' -e '}' -e '/^KODZA-JS$/{r kodza/kodza-page-lift.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-page-lift', $uri . 'kodza-page-lift.css', [], filemtime($dir . 'kodza-page-lift.css'));
    wp_enqueue_script('kodza-page-lift', $uri . 'kodza-page-lift.js', [], filemtime($dir . 'kodza-page-lift.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>`).
- Load the CSS and the script on every page, the script in the <head>: the lift needs both pages. A page without the section takes its settings on html (--kz-page-lift-*). Swup or Barba: dispatch kodza:page-transition with your swap as update. Firefox has no lift between two documents yet: it loads the next page without it (with Swup or Barba, it lifts there too).

#### 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. This one reads the page around it, and a script in the widget runs before the rest of the page exists: put its `<script>` in an HTML widget at the bottom of the page instead.

### Shopify

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

export type PageLiftProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars" | "onPageEngine" | "onPageTransition"> & {
  /** Settings, as CSS variables on the root */
  vars?: {
    /** the lift, both ways — default: 0.8s */
    "--kz-page-lift-duration"?: string | number;
    /** default: cubic-bezier(0.76, 0, 0.24, 1) */
    "--kz-page-lift-ease"?: string | number;
    /** how far the page you leave recedes — default: 0.92 */
    "--kz-page-lift-depth"?: string | number;
    /** and how dark it gets — default: 0.55 */
    "--kz-page-lift-veil"?: string | number;
    /** the next page starts this far down, under its own top edge — default: 16% */
    "--kz-page-lift-rise"?: string | number;
    /** its top corners, flat once it lands — default: 20px */
    "--kz-page-lift-radius"?: string | number;
    /** behind the page that recedes — default: #0a0a0a */
    "--kz-page-lift-backdrop"?: string | number;
    /** the arrival starts while the page is still rising — default: 0.36s */
    "--kz-page-lift-enter-delay"?: string | number;
    /** default: cubic-bezier(0.2, 0.8, 0.1, 1) */
    "--kz-page-lift-enter-ease"?: string | number;
    /** the image uncovering: gentle start, no flash — default: cubic-bezier(0.65, 0, 0.35, 1) */
    "--kz-page-lift-reveal-ease"?: string | number;
    /** default: #f2f2f0 */
    "--kz-page-lift-bg"?: string | number;
    /** default: #0a0a0a */
    "--kz-page-lift-fg"?: string | number;
    /** default: rgba(10, 10, 10, 0.62) */
    "--kz-page-lift-muted"?: string | number;
    /** default: rgba(10, 10, 10, 0.12) */
    "--kz-page-lift-line"?: string | number;
    /** default: 16px */
    "--kz-page-lift-image-radius"?: string | number;
    /** default: 24px */
    "--kz-page-lift-side"?: string | number;
  };
  /** "kodza:page-engine", fired by the script (bubbles from the root) */
  onPageEngine?: (event: CustomEvent) => void;
  /** "kodza:page-transition", fired by the script (bubbles from the root) */
  onPageTransition?: (event: CustomEvent) => void;
  className?: string;
};

export function PageLift({ vars, onPageEngine, onPageTransition, className, style, ...rest }: PageLiftProps) {
  const ref = useRef<ComponentRef<'section'>>(null);
  const classes = ["kodza-page-lift", 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('./page-lift.js').then(() => {
      if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
    });
    return () => {
      live = false;
      document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
    };
  }, [key]);

  useEffect(() => {
    const root = ref.current;
    if (!root || !onPageEngine) return;
    const listener = (event: Event) => onPageEngine(event as CustomEvent);
    root.addEventListener("kodza:page-engine", listener);
    return () => root.removeEventListener("kodza:page-engine", listener);
  }, [onPageEngine, key]);

  useEffect(() => {
    const root = ref.current;
    if (!root || !onPageTransition) return;
    const listener = (event: Event) => onPageTransition(event as CustomEvent);
    root.addEventListener("kodza:page-transition", listener);
    return () => root.removeEventListener("kodza:page-transition", listener);
  }, [onPageTransition, key]);

  return (
    <section data-kodza-page-lift="" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
      <p className="kodza-page-lift__label" data-enter="label">Case study · 04 / 12</p>
      <div className="kodza-page-lift__split">
        <h1 className="kodza-page-lift__title" data-enter="title">The deploy that went out on a Friday</h1>
        <div className="kodza-page-lift__intro">
          <p className="kodza-page-lift__lead" data-enter="">A full redesign, shipped four minutes before the weekend. No rollback, no war room, nobody paged. Here is how a team of three made it boring on purpose.</p>
          <dl className="kodza-page-lift__facts">
            <div data-enter="">
              <dt>Client</dt>
              <dd>{"Null & Void"}</dd>
            </div>
            <div data-enter="">
              <dt>Year</dt>
              <dd>2026</dd>
            </div>
            <div data-enter="">
              <dt>Shipped</dt>
              <dd>Fri, 17:56</dd>
            </div>
          </dl>
        </div>
      </div>
      <figure className="kodza-page-lift__media">
        <div className="kodza-page-lift__frame" data-enter="image">
          <img className="kodza-page-lift__image" src="https://kodza.dev/media/kz-steps-2400.webp" width="2400" height="1600" alt="Concrete steps climbing through bare grey walls" />
        </div>
        <figcaption data-enter="">
          <span>fig. 1 — the release plan, in its final form</span>
          <span>git push, then leave</span>
        </figcaption>
      </figure>
    </section>
  );
}
```

## Code

```html
<section class="kodza-page-lift" data-kodza-page-lift>
  <p class="kodza-page-lift__label" data-enter="label">Case study · 04 / 12</p>
  <div class="kodza-page-lift__split">
    <h1 class="kodza-page-lift__title" data-enter="title">The deploy that went out on a Friday</h1>
    <div class="kodza-page-lift__intro">
      <p class="kodza-page-lift__lead" data-enter>A full redesign, shipped four minutes before the weekend. No rollback, no war room, nobody paged. Here is how a team of three made it boring on purpose.</p>
      <dl class="kodza-page-lift__facts">
        <div data-enter><dt>Client</dt><dd>Null &amp; Void</dd></div>
        <div data-enter><dt>Year</dt><dd>2026</dd></div>
        <div data-enter><dt>Shipped</dt><dd>Fri, 17:56</dd></div>
      </dl>
    </div>
  </div>
  <figure class="kodza-page-lift__media">
    <div class="kodza-page-lift__frame" data-enter="image">
      <img class="kodza-page-lift__image" src="https://kodza.dev/media/kz-steps-2400.webp" width="2400" height="1600" alt="Concrete steps climbing through bare grey walls">
    </div>
    <figcaption data-enter><span>fig. 1 — the release plan, in its final form</span><span>git push, then leave</span></figcaption>
  </figure>
</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 — Page Lift
   A page transition between two real pages (cross-document View Transitions). On a click the
   page you leave recedes and darkens, the next one rises over it with a rounded top edge that
   flattens as it lands; the site header stays where it is. Then the arrival plays: the label,
   the title rising line by line from its mask, the image uncovering as it tightens, the rest in
   a cascade (mark them with data-enter="label", "title", "image", or just data-enter). Going
   back plays the lift in reverse: the page goes down as it came. Without the API (Firefox) or
   with reduced motion, navigation stays plain. Tuned with the --kz-page-lift-* variables, on
   .kodza-page-lift (or on html, for the pages without the section). */
.kodza-page-lift {
  --kz-page-lift-duration: 0.8s; /* the lift, both ways */
  --kz-page-lift-ease: cubic-bezier(0.76, 0, 0.24, 1);
  --kz-page-lift-depth: 0.92; /* how far the page you leave recedes */
  --kz-page-lift-veil: 0.55; /* and how dark it gets */
  --kz-page-lift-rise: 16%; /* the next page starts this far down, under its own top edge */
  --kz-page-lift-radius: 20px; /* its top corners, flat once it lands */
  --kz-page-lift-backdrop: #0a0a0a; /* behind the page that recedes */
  --kz-page-lift-enter-delay: 0.36s; /* the arrival starts while the page is still rising */
  --kz-page-lift-enter-ease: cubic-bezier(0.2, 0.8, 0.1, 1);
  --kz-page-lift-reveal-ease: cubic-bezier(0.65, 0, 0.35, 1); /* the image uncovering: gentle start, no flash */
  --kz-page-lift-bg: #f2f2f0;
  --kz-page-lift-fg: #0a0a0a;
  --kz-page-lift-muted: rgba(10, 10, 10, 0.62);
  --kz-page-lift-line: rgba(10, 10, 10, 0.12);
  --kz-page-lift-image-radius: 16px;
  --kz-page-lift-side: 24px;

  box-sizing: border-box;
  padding: 64px var(--kz-page-lift-side) 72px;
  background: var(--kz-page-lift-bg);
  color: var(--kz-page-lift-fg);
  font-size: 15px;
  line-height: 1.5;
  text-align: left;
}

/* ---------- The lift: both pages, drawn by the browser between two documents ---------- */
@view-transition {
  navigation: auto;
}

/* The script marks the page (kodza-page-lift-on); without it, a page with the section still lifts */
:is(html.kodza-page-lift-on, html:has(.kodza-page-lift):not(.kodza-page-engine))::view-transition {
  background: var(--kz-page-lift-backdrop, #0a0a0a);
}

:is(html.kodza-page-lift-on, html:has(.kodza-page-lift):not(.kodza-page-engine))::view-transition-group(root) {
  animation: none;
}

:is(html.kodza-page-lift-on, html:has(.kodza-page-lift):not(.kodza-page-engine))::view-transition-old(root),
:is(html.kodza-page-lift-on, html:has(.kodza-page-lift):not(.kodza-page-engine))::view-transition-new(root) {
  mix-blend-mode: normal;
  animation-duration: var(--kz-page-lift-duration, 0.8s);
  animation-timing-function: var(--kz-page-lift-ease, cubic-bezier(0.76, 0, 0.24, 1));
  animation-delay: calc(var(--kz-page-lift-at, 0) * var(--kz-page-lift-duration, 0.8s) * -1); /* an interrupted lift goes on from where it was */
  animation-fill-mode: both;
}

:is(html.kodza-page-lift-on, html:has(.kodza-page-lift):not(.kodza-page-engine))::view-transition-old(root) {
  animation-name: kodza-page-lift-recede;
}

:is(html.kodza-page-lift-on, html:has(.kodza-page-lift):not(.kodza-page-engine))::view-transition-new(root) {
  animation-name: kodza-page-lift-rise;
}

/* Back: the same lift played backwards, so the page goes down exactly as it came up */
:is(html.kodza-page-lift-on, html:has(.kodza-page-lift):not(.kodza-page-engine)).kodza-page-lift-back::view-transition-old(root) {
  z-index: 1;
  animation-name: kodza-page-lift-rise;
  animation-direction: reverse;
}

:is(html.kodza-page-lift-on, html:has(.kodza-page-lift):not(.kodza-page-engine)).kodza-page-lift-back::view-transition-new(root) {
  animation-name: kodza-page-lift-recede;
  animation-direction: reverse;
}

/* The header stays: the script names it on both pages, the new one is shown from the start */
:is(html.kodza-page-lift-on, html:has(.kodza-page-lift):not(.kodza-page-engine))::view-transition-group(*.kodza-page-lift-stay) {
  animation: none;
}

:is(html.kodza-page-lift-on, html:has(.kodza-page-lift):not(.kodza-page-engine))::view-transition-old(*.kodza-page-lift-stay) {
  animation: none;
  opacity: 0;
}

/* It fills its group: Firefox keeps the proportions of its first capture, so a header that
   widens while the page changes (a pill growing back to full width) would grow taller with it */
:is(html.kodza-page-lift-on, html:has(.kodza-page-lift):not(.kodza-page-engine))::view-transition-new(*.kodza-page-lift-stay) {
  height: 100%;
  mix-blend-mode: normal;
  animation: none;
}

@keyframes kodza-page-lift-recede {
  to {
    transform: translateY(-3%) scale(var(--kz-page-lift-depth, 0.92));
    filter: brightness(calc(1 - var(--kz-page-lift-veil, 0.55)));
  }
}

/* It starts one radius tall: from an empty box, Chrome's compositor flattens the corners for
   the whole lift */
@keyframes kodza-page-lift-rise {
  from {
    transform: translateY(var(--kz-page-lift-rise, 16%));
    clip-path: inset(calc(100% - var(--kz-page-lift-radius, 20px)) 0 0 0 round var(--kz-page-lift-radius, 20px) var(--kz-page-lift-radius, 20px) 0 0);
  }

  to {
    transform: none;
    clip-path: inset(0 0 0 0 round 0 0 0 0);
  }
}

/* ---------- The arrival: hidden from the first paint, until the script plays it ---------- */
@media (prefers-reduced-motion: no-preference) {
  html.kodza-page-lift-enter .kodza-page-lift [data-enter] {
    opacity: 0;
    animation: kodza-page-lift-failsafe 0.5s 2.5s both; /* no script: the page shows anyway */
  }
}

@keyframes kodza-page-lift-failsafe {
  to {
    opacity: 1;
  }
}

/* The title, cut into words by the script: each one rises from behind its own edge */
.kodza-page-lift__mask {
  display: inline-block;
  vertical-align: top;
  margin-bottom: -0.16em;
  padding-bottom: 0.16em; /* room for the descenders, inside the mask */
}

.kodza-page-lift.is-entering .kodza-page-lift__mask {
  clip-path: inset(-0.3em -0.2em 0 -0.2em); /* open above, for the accents */
}

.kodza-page-lift__word {
  display: inline-block;
}

/* ---------- The section: a case study opening ---------- */
.kodza-page-lift__label,
.kodza-page-lift__facts dt,
.kodza-page-lift__media figcaption {
  margin: 0;
  font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12px;
  font-weight: 400;
  line-height: 1.4;
  letter-spacing: 0;
  color: var(--kz-page-lift-muted);
}

.kodza-page-lift__label {
  margin-bottom: 24px;
}

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

.kodza-page-lift__title {
  margin: 0;
  color: inherit;
  font-family: inherit;
  font-size: clamp(44px, 6.6vw, 96px);
  font-weight: 600;
  line-height: 0.95;
  letter-spacing: -0.055em;
  text-wrap: balance;
}

.kodza-page-lift__intro {
  display: grid;
  gap: 32px;
  padding-bottom: 6px;
}

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

.kodza-page-lift__facts {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 16px;
  margin: 0;
  padding: 0;
}

.kodza-page-lift__facts > div {
  padding-top: 12px;
  border-top: 1px solid var(--kz-page-lift-line);
}

.kodza-page-lift__facts dd {
  margin: 6px 0 0;
  font-size: 15px;
  font-weight: 500;
  letter-spacing: -0.01em;
}

.kodza-page-lift__media {
  margin: 56px 0 0;
}

.kodza-page-lift__frame {
  overflow: hidden;
  aspect-ratio: 21 / 9;
  border-radius: var(--kz-page-lift-image-radius);
  background: var(--kz-page-lift-line);
}

.kodza-page-lift__image {
  display: block;
  width: 100%;
  height: 100%;
  max-width: none;
  margin: 0;
  object-fit: cover;
}

.kodza-page-lift__media figcaption {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  margin-top: 12px;
}

@media (max-width: 860px) {
  .kodza-page-lift {
    padding-top: 48px;
  }

  .kodza-page-lift__split {
    grid-template-columns: minmax(0, 1fr);
  }

  .kodza-page-lift__media {
    margin-top: 40px;
  }

  .kodza-page-lift__frame {
    aspect-ratio: 4 / 3;
  }
}

@media (max-width: 480px) {
  .kodza-page-lift__media figcaption span + span {
    display: none;
  }
}

/* Reduced motion: plain navigation, the page straight away */
@media (prefers-reduced-motion: reduce) {
  @view-transition {
    navigation: none;
  }
}

/* kodza-guard 9d0677c759a1 — 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, on everything the component leaves to it */

.kodza-page-lift__label:not(#_),
.kodza-page-lift__title:not(#_),
.kodza-page-lift__facts dt:where(:not([class*="kodza-"]):not(.kodza-page-lift__facts [class*="kodza-"] *)):not(#_),
.kodza-page-lift__media figcaption:where(:not([class*="kodza-"]):not(.kodza-page-lift__media [class*="kodza-"] *)):not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-style: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-page-lift__split:not(#_),
.kodza-page-lift__mask:not(#_),
.kodza-page-lift__word:not(#_),
.kodza-page-lift__intro:not(#_),
.kodza-page-lift__frame:not(#_),
.kodza-page-lift__facts div:where(:not([class*="kodza-"]):not(.kodza-page-lift__facts [class*="kodza-"] *)):not(#_),
.kodza-page-lift__media span:where(:not([class*="kodza-"]):not(.kodza-page-lift__media [class*="kodza-"] *)):not(#_) {
  box-sizing: revert;
}

.kodza-page-lift__lead:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-height: revert;
  font-family: revert;
  font-weight: revert;
  font-style: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-page-lift__facts:not(#_) {
  border: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-page-lift__media:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-page-lift__image:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-height: revert;
  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-page-lift__facts dd:where(:not([class*="kodza-"]):not(.kodza-page-lift__facts [class*="kodza-"] *)):not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-style: revert;
  line-height: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-page-lift__label:not(#_):where(:not([hidden])),
.kodza-page-lift__title:not(#_):where(:not([hidden])),
.kodza-page-lift__lead:not(#_):where(:not([hidden])),
.kodza-page-lift__media:not(#_):where(:not([hidden])),
.kodza-page-lift__facts dt:where(:not([class*="kodza-"]):not(.kodza-page-lift__facts [class*="kodza-"] *)):not(#_):where(:not([hidden])),
.kodza-page-lift__facts dd:where(:not([class*="kodza-"]):not(.kodza-page-lift__facts [class*="kodza-"] *)):not(#_):where(:not([hidden])) {
  display: revert;
}

.kodza-page-lift__label:not(#_):where([hidden]),
.kodza-page-lift__title:not(#_):where([hidden]),
.kodza-page-lift__lead:not(#_):where([hidden]),
.kodza-page-lift__media:not(#_):where([hidden]),
.kodza-page-lift__facts dt:where(:not([class*="kodza-"]):not(.kodza-page-lift__facts [class*="kodza-"] *)):not(#_):where([hidden]),
.kodza-page-lift__facts dd:where(:not([class*="kodza-"]):not(.kodza-page-lift__facts [class*="kodza-"] *)):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-page-lift__label:not(#_),
.kodza-page-lift__facts dt:not(#_),
.kodza-page-lift__media figcaption:not(#_) {
  margin: 0;
  font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12px;
  font-weight: 400;
  line-height: 1.4;
  letter-spacing: 0;
  color: var(--kz-page-lift-muted);
}

.kodza-page-lift__label:not(#_) {
  margin-bottom: 24px;
}

.kodza-page-lift__title:not(#_) {
  margin: 0;
  color: inherit;
  font-family: inherit;
  font-size: clamp(44px, 6.6vw, 96px);
  font-weight: 600;
  line-height: 0.95;
  letter-spacing: -0.055em;
}

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

.kodza-page-lift__facts:not(#_) {
  display: grid;
  margin: 0;
  padding: 0;
}

.kodza-page-lift__facts dd:not(#_) {
  margin: 6px 0 0;
  font-size: 15px;
  font-weight: 500;
  letter-spacing: -0.01em;
}

.kodza-page-lift__media:not(#_) {
  margin: 56px 0 0;
}

.kodza-page-lift__image:not(#_) {
  display: block;
  max-width: none;
  margin: 0;
}

.kodza-page-lift__media figcaption:not(#_) {
  display: flex;
  margin-top: 12px;
}

@media (max-width: 860px) {
  .kodza-page-lift__media:not(#_) {
    margin-top: 40px;
  }
}
```

```js
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Page Lift
// The lift itself is CSS: between two real pages the browser draws it (cross-document View
// Transitions). The script gives it its direction (back plays it in reverse, from wherever an
// interrupted lift was), keeps the header in place, plays the arrival of the new page, and runs
// the same lift inside one page for sites that swap their content (kodza:page-transition).
(() => {
  const SELECTOR = '[data-kodza-page-lift]';

  // The two lines that change from one page transition of the family to the next
  const NAME = 'page-lift';
  const VARS = ['duration', 'ease', 'depth', 'veil', 'rise', 'radius', 'backdrop'];

  // ---------- Page engine (begin) ----------
  // The same block in every page transition of the family (Page Lift, Curtain, Stairs...),
  // copied as is. The page you leave writes a short record (direction, where an interrupted
  // transition was, which headers stay); the page that lands reads it before its first paint.
  // Pages: data-page-back on a link plays the return (an "All work" link), data-page-stay marks
  // what stays in place (the site header by default), data-enter the parts of the arrival.
  const html = document.documentElement;
  const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
  const ON = `kodza-${NAME}-on`;
  const BACK = `kodza-${NAME}-back`;
  const ENTER = `kodza-${NAME}-enter`;
  const STAY = `kodza-${NAME}-stay`;
  const KEY = 'kodza-page-engine';
  const WAIT = 5000; // ms a turned-round transition can wait for its content swap
  const navType = performance.getEntriesByType('navigation')[0]?.type;
  const signals = new WeakMap(); // root -> its signal, for its arrival
  const entries = new WeakMap(); // root -> its arrival, prepared or playing
  const pending = new Set(); // arrivals waiting for their start
  let go = null; // when the arrival starts, once the page knows
  let current = null; // the transition running
  let heading = null; // where it goes: true back, false forward
  let local = null; // the one this page runs itself, around a content swap
  let turn = null; // the swap waiting for the page on screen to be out of sight
  let hold = null; // its animations, kept on until the swap is done
  let stayed = [];
  let pressed = null;
  let revealed = false;

  // One engine per page: an earlier one (another transition of the family) steps aside
  document.dispatchEvent(new CustomEvent('kodza:page-engine'));
  const page = new AbortController();
  document.addEventListener('kodza:page-engine', () => {
    page.abort();
    html.classList.remove(ON, BACK, ENTER);
  }, { once: true, signal: page.signal });
  html.classList.add(ON, 'kodza-page-engine');

  const here = (href) => {
    const url = new URL(href, location.href);
    url.hash = '';
    return url.href;
  };
  const ms = (value, fallback) => {
    const n = parseFloat(value);
    return Number.isNaN(n) ? fallback : /ms\s*$/.test(value) ? n : n * 1000;
  };
  const now = () => document.timeline.currentTime;
  const shown = () => [...document.querySelectorAll(SELECTOR)].filter((root) => root.getClientRects().length);

  // Where the transition running is, as a share of the way forward (null: none is)
  function progress() {
    const run = document.getAnimations().find((a) => a.animationName?.startsWith(`kodza-${NAME}`) && a.effect?.pseudoElement?.startsWith('::view-transition'));
    if (!run) return null;
    const { delay, duration } = run.effect.getTiming();
    const t = Math.min(Math.max((run.currentTime ?? 0) - delay, 0), duration) / duration;
    return html.classList.contains(BACK) ? 1 - t : t;
  }

  // Settings of the section that decides (the one that arrives, or the one you leave going back)
  function values(root) {
    if (!root) return null;
    const style = getComputedStyle(root);
    const out = {};
    VARS.forEach((v) => {
      const value = style.getPropertyValue(`--kz-${NAME}-${v}`).trim();
      if (value) out[v] = value;
    });
    return out;
  }
  const apply = (out) => Object.entries(out || {}).forEach(([v, value]) => html.style.setProperty(`--kz-${NAME}-${v}`, value));

  // What stays in place: [data-page-stay], or the site header (and the WordPress admin bar)
  // when it sits at the top of the screen. Going back through history, a header that scrolls
  // with its page may not be at the top of the next one: only a fixed or sticky one stays.
  function candidates() {
    const marked = document.querySelectorAll('[data-page-stay]');
    if (marked.length) return [...marked];
    return [...document.querySelectorAll('header, #wpadminbar')].filter((el) => !el.parentElement?.closest(`header, main, article, section, aside, footer, ${SELECTOR}`));
  }
  function stays(el, traverse) {
    const box = el.getBoundingClientRect();
    if (!box.height || box.top < -1 || box.top > 64 || box.height > innerHeight * 0.4) return false;
    if (!traverse) return true;
    for (let node = el; node && node !== html; node = node.parentElement) {
      if (/fixed|sticky/.test(getComputedStyle(node).position)) return true;
    }
    return false;
  }
  function release() {
    stayed.forEach((el) => {
      el.style.removeProperty('view-transition-name');
      el.style.removeProperty('view-transition-class');
    });
    stayed = [];
  }
  function name(only, traverse) {
    release();
    const named = [];
    candidates().forEach((el, i) => {
      if ((only && !only.includes(i)) || !stays(el, traverse)) return;
      el.style.setProperty('view-transition-name', `${STAY}-${i}`);
      el.style.setProperty('view-transition-class', STAY);
      stayed.push(el);
      named.push(i);
    });
    return named;
  }
  function unturn() {
    hold?.forEach((a) => a.playState !== 'idle' && a.finish());
    hold = null;
    turn = null;
  }
  function clean() {
    unturn();
    heading = null;
    html.classList.remove(BACK);
    html.style.removeProperty(`--kz-${NAME}-at`);
    VARS.forEach((v) => html.style.removeProperty(`--kz-${NAME}-${v}`));
    release();
  }

  // The record the page you leave writes, read once by the next one
  function read(restored) {
    let record = null;
    try {
      record = JSON.parse(sessionStorage.getItem(KEY));
      sessionStorage.removeItem(KEY);
    } catch (error) {
      record = null;
    }
    if (record && (Date.now() - record.time > 4000 || (record.to && record.to !== here(location.href)))) record = null;
    const activation = window.navigation?.activation;
    const from = activation?.from;
    const back = record ? record.back
      : activation?.navigationType === 'traverse' ? !from || from.index < 0 || activation.entry.index < from.index
        : restored || navType === 'back_forward';
    // An interrupted transition turned around goes on from where it was
    const at = !record || record.p == null || record.was === back ? 0 : back ? 1 - record.p : record.p;
    return { back, at, stay: record?.stay || [], vars: record?.vars };
  }

  // The arrival: label, title line by line from its mask, image uncovering as it tightens, the
  // rest in a cascade. Prepared hidden (paused on its first frame), started once the page knows.
  // The title is cut into words for it, and put back as it was once they have landed.
  function split(title) {
    const nodes = [];
    const walker = document.createTreeWalker(title, NodeFilter.SHOW_TEXT);
    while (walker.nextNode()) nodes.push(walker.currentNode);
    const words = [];
    nodes.forEach((node) => {
      const fragment = document.createDocumentFragment();
      node.data.split(/(\s+)/).forEach((piece) => {
        if (!piece) return;
        if (!piece.trim()) {
          fragment.append(piece);
          return;
        }
        const mask = document.createElement('span');
        const word = document.createElement('span');
        mask.className = `kodza-${NAME}__mask`;
        word.className = `kodza-${NAME}__word`;
        word.textContent = piece;
        mask.append(word);
        fragment.append(mask);
        words.push(word);
      });
      node.replaceWith(fragment);
    });
    return words;
  }
  function prepare(root) {
    const signal = signals.get(root);
    if (!signal || signal.aborted || motion.matches) return;
    entries.get(root)?.stop();
    const parts = [...root.querySelectorAll('[data-enter]')];
    if (!parts.length) return;
    const style = getComputedStyle(root);
    const easing = style.getPropertyValue(`--kz-${NAME}-enter-ease`).trim() || 'cubic-bezier(0.2, 0.8, 0.1, 1)';
    const reveal = style.getPropertyValue(`--kz-${NAME}-reveal-ease`).trim() || 'cubic-bezier(0.65, 0, 0.35, 1)';
    const delay = ms(style.getPropertyValue(`--kz-${NAME}-enter-delay`), 360);
    const title = parts.find((el) => el.dataset.enter === 'title');
    const markup = title?.innerHTML;
    const words = title ? split(title) : [];
    const anims = [];
    const add = (el, keyframes, duration, ease = easing) => {
      const anim = el.animate(keyframes, { duration, easing: ease, fill: 'both' });
      anim.pause();
      anims.push(anim);
      return anim;
    };
    root.classList.add('is-entering');
    parts.filter((el) => el.dataset.enter === 'label').forEach((el) => add(el, [{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], 500));
    const lines = words.map((word) => [word, add(word, [{ transform: 'translateY(calc(110% + 0.16em))' }, { transform: 'none' }], 850)]);
    const image = parts.filter((el) => el.dataset.enter === 'image').flatMap((el) => {
      const media = el.matches('img, video, picture') ? null : el.querySelector('img, video, picture');
      const round = getComputedStyle(el).borderTopLeftRadius || '0px';
      return [
        // A big image uncovers slowly at first: with the entry curve, half of it would show in 100 ms.
        // Its lower edge rounds as it reaches the corners (Chrome's compositor draws it so anyway).
        add(el, [{ clipPath: 'inset(0 0 100% 0 round 0px)' }, { clipPath: `inset(0 0 0% 0 round ${round})` }], 900, reveal),
        ...(media ? [add(media, [{ transform: 'scale(1.25)' }, { transform: 'none' }], 1300)] : []),
      ];
    });
    const rest = parts.filter((el) => !['label', 'title', 'image'].includes(el.dataset.enter)).map((el) => add(el, [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], 650));
    const stop = () => {
      anims.forEach((anim) => anim.cancel());
      if (title && title.innerHTML !== markup) title.innerHTML = markup;
      root.classList.remove('is-entering');
      pending.delete(entry);
      if (entries.get(root) === entry) entries.delete(root);
    };
    const entry = {
      stop,
      start({ time, lifted }) {
        // Lines are measured now, in their final font: the words of a line rise together
        let top = null;
        let line = -1;
        lines.forEach(([word, anim]) => {
          const y = Math.round(word.parentElement.getBoundingClientRect().top);
          if (y !== top) {
            top = y;
            line += 1;
          }
          anim.effect.updateTiming({ delay: 60 + line * 90 });
        });
        image.forEach((anim) => anim.effect.updateTiming({ delay: 120 }));
        rest.forEach((anim, i) => anim.effect.updateTiming({ delay: 60 + (line + 1) * 90 + i * 70 }));
        const at = Math.max(time + (lifted ? delay : 0), now());
        anims.forEach((anim) => {
          anim.startTime = at;
        });
        Promise.all(anims.map((anim) => anim.finished)).then(stop, () => {});
      },
    };
    entries.set(root, entry);
    signal.addEventListener('abort', stop, { once: true });
    if (go) entry.start(go);
    else pending.add(entry);
  }
  function arrive(time, lifted) {
    go = { time, lifted };
    pending.forEach((entry) => entry.start(go));
    pending.clear();
  }

  // Inside one page (Swup, an app, the demo): the same transition around a content swap.
  //   document.dispatchEvent(new CustomEvent('kodza:page-transition', { cancelable: true,
  //     detail: { update: () => swapTheContent(), back: false } })) || nothing to do
  // dispatchEvent() returns true when no engine took it: then run update() yourself.
  function swap(update, back) {
    const lift = Boolean(document.startViewTransition) && !motion.matches;
    if (lift && current && current === local && turn && back === heading) {
      const first = turn; // already on its way there: this swap waits its turn too
      turn = async () => {
        await first();
        await update();
      };
      return;
    }
    if (lift && current && current === local && !turn && back !== heading && reverse(update, back)) return;
    unturn();
    const before = shown();
    const p = lift ? progress() : null;
    const at = p == null || back === heading ? 0 : back ? 1 - p : p;
    const stay = lift ? name(null, false) : [];
    const run = async () => {
      await update();
      init();
      const after = shown();
      const arriving = after.filter((root) => !before.includes(root));
      if (lift) {
        VARS.forEach((v) => html.style.removeProperty(`--kz-${NAME}-${v}`));
        apply(back ? values(before.find((root) => !after.includes(root))) || values(after[0]) : values(arriving[0]) || values(after[0]));
        name(stay, false);
      }
      go = null;
      if (!back && !at) arriving.forEach(prepare);
    };
    if (!lift) {
      run().then(() => arrive(now(), false));
      return;
    }
    const vt = document.startViewTransition(run);
    current = vt;
    local = vt;
    heading = back;
    html.classList.toggle(BACK, back);
    html.style.setProperty(`--kz-${NAME}-at`, at);
    vt.ready.then(() => arrive(now(), true), () => arrive(now(), false));
    vt.finished.finally(() => {
      if (current !== vt) return;
      current = null;
      clean();
    });
  }
  // Turned round in the middle (back while it lifts, or the other way): the same transition
  // plays backwards from where it is, and the content swaps once it is back where it started,
  // the page it was showing out of sight. A new one would first show that page whole, a frame.
  // It goes back to the page it came from, as a "back" does. Its animations wait at their start
  // (a longer delay, the same place) until the swap is done: the transition stays on screen.
  function reverse(update, back) {
    const runs = document.getAnimations().filter((a) => a.effect?.pseudoElement?.startsWith('::view-transition') && a.playState === 'running');
    if (!runs.length) return false;
    runs.forEach((a) => {
      const time = a.currentTime;
      a.effect.updateTiming({ delay: a.effect.getTiming().delay + WAIT });
      a.currentTime = time + WAIT;
      a.updatePlaybackRate(-a.playbackRate);
    });
    hold = runs;
    heading = back;
    turn = update;
    const wait = () => {
      if (hold !== runs) return;
      if (runs.some((a) => a.currentTime > WAIT)) {
        requestAnimationFrame(wait);
        return;
      }
      Promise.resolve()
        .then(() => turn())
        .then(init)
        .finally(() => hold === runs && unturn());
    };
    requestAnimationFrame(wait);
    return true;
  }
  document.addEventListener('kodza:page-transition', (event) => {
    if (typeof event.detail?.update !== 'function') return;
    event.preventDefault();
    swap(event.detail.update, Boolean(event.detail.back));
  }, { signal: page.signal });

  // Between two documents: the page you leave writes the record...
  document.addEventListener('click', (event) => {
    const link = event.target.closest?.('a[href]');
    pressed = link?.hasAttribute('data-page-back') ? { href: here(link.href), time: Date.now() } : null;
  }, { capture: true, signal: page.signal });
  // While the pages trade places the browser sends every click to the page itself: one on the
  // header that stays goes on to the link under it (back, in the middle of a lift, turns it round).
  // After the listener above, which would forget the link pressed.
  document.addEventListener('click', (event) => {
    if (!current || event.target !== html) return;
    const { clientX: x, clientY: y } = event;
    const hit = stayed.flatMap((el) => [...el.querySelectorAll('a[href], button')]).find((el) => {
      const box = el.getBoundingClientRect();
      return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
    });
    hit?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window, detail: 1, clientX: x, clientY: y, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, altKey: event.altKey }));
  }, { capture: true, signal: page.signal });
  window.addEventListener('pageswap', (event) => {
    if (!event.viewTransition) return;
    const activation = event.activation;
    const traverse = activation?.navigationType === 'traverse';
    const to = activation?.entry?.url ? here(activation.entry.url) : '';
    const clicked = Boolean(pressed) && Date.now() - pressed.time < 4000 && (!to || pressed.href === to);
    const record = {
      to,
      back: traverse ? activation.entry.index < activation.from.index : clicked,
      p: progress(),
      was: heading,
      stay: name(null, traverse),
      vars: values(shown()[0]),
      time: Date.now(),
    };
    try {
      sessionStorage.setItem(KEY, JSON.stringify(record));
    } catch (error) {
      // no storage: the next page still lifts, forward
    }
  }, { signal: page.signal });

  // ...and the page that lands reads it before its first paint
  let landing = read(false);
  if (!motion.matches && !landing.back && !landing.at && navType !== 'back_forward' && !performance.getEntriesByType('paint').length) html.classList.add(ENTER);
  window.addEventListener('pagereveal', (event) => {
    const land = landing || read(true); // later ones: the page comes back from the back/forward cache
    landing = null;
    revealed = true;
    clean();
    const vt = event.viewTransition;
    if (!vt) {
      arrive(now(), false);
      return;
    }
    current = vt;
    heading = land.back;
    html.classList.toggle(BACK, land.back);
    if (land.at) html.style.setProperty(`--kz-${NAME}-at`, land.at);
    const own = values(shown()[0]);
    apply(land.back ? land.vars || own : own || land.vars);
    name(land.stay, false);
    vt.ready.then(() => arrive(now(), true), () => arrive(now(), false));
    vt.finished.finally(() => {
      if (current !== vt) return;
      current = null;
      clean();
    });
  }, { signal: page.signal });
  // No pagereveal (Firefox, older Safari), or the script came after it: the arrival plays now
  requestAnimationFrame(() => {
    if (!revealed) arrive(now(), false);
  });
  // Once the sections of the page are prepared, the class goes: one added later starts shown
  const settle = () => requestAnimationFrame(() => html.classList.remove(ENTER));
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', settle, { once: true, signal: page.signal });
  else settle();
  // ---------- Page 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) {
    signals.set(root, signal);
    if (!html.classList.contains(ENTER)) return;
    prepare(root); // hidden from the first paint by the CSS, then by its own first frame
    queueMicrotask(() => html.classList.remove(ENTER));
  }

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