# Stairs Transition

> Between two real pages, five ink bands climb over the page like a staircase, then step off onto the next one. Back starts from the other edge. Chrome, Edge, Safari.

Preloaders & transitions · page transition, view transitions, multi-page, stairs, portfolio, swup · HTML, CSS, vanilla JS · no dependencies · updated 2026-10-31
Demo: https://kodza.dev/components/stairs-transition/
Source on GitHub: https://github.com/kodza-dev/kodza/tree/main/components/stairs-transition

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-stairs-transition.css`](https://kodza.dev/registry/stairs-transition/kodza-stairs-transition.css) · 23.6 KB
- [`kodza-stairs-transition.js`](https://kodza.dev/registry/stairs-transition/kodza-stairs-transition.js) · 25.0 KB
- [`kodza-stairs-transition.html`](https://kodza.dev/registry/stairs-transition/kodza-stairs-transition.html) · 1.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-stairs-transition.css https://kodza.dev/registry/stairs-transition/kodza-stairs-transition.css
curl -fsSL --create-dirs -o kodza/kodza-stairs-transition.js https://kodza.dev/registry/stairs-transition/kodza-stairs-transition.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-stairs-transition]`
- Variables (set them on the root element itself: it declares its own defaults):
  - `--kz-stairs-transition-duration: 1.2s` — the whole staircase, first band up to last band off
  - `--kz-stairs-transition-stagger: 0.05s` — from one band to the next
  - `--kz-stairs-transition-ease: cubic-bezier(0.76, 0, 0.24, 1)`
  - `--kz-stairs-transition-ink: #0a0a0a` — the bands
  - `--kz-stairs-transition-way: 1` — 1: up from the bottom, -1: down from the top
  - `--kz-stairs-transition-enter-delay: 0.8s` — the arrival starts as the bands step off it
  - `--kz-stairs-transition-enter-ease: cubic-bezier(0.2, 0.8, 0.1, 1)`
  - `--kz-stairs-transition-bg: #f2f2f0`
  - `--kz-stairs-transition-fg: #0a0a0a`
  - `--kz-stairs-transition-muted: rgba(10, 10, 10, 0.62)`
  - `--kz-stairs-transition-line: rgba(10, 10, 10, 0.12)`
  - `--kz-stairs-transition-image-radius: calc(16px * var(--kz-radius-scale, 1))`
  - `--kz-stairs-transition-side: 24px`
- Modifiers: `.kodza-stairs-transition--from-top`
- Attributes:
  - `data-enter` (e.g. `label`, `title`, `image`)
  - `data-kodza-stairs-transition`
  - `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-stairs-transition {
    --kz-stairs-transition-bg: #0a0a0a;
    --kz-stairs-transition-fg: #f2f2f0;
    --kz-stairs-transition-muted: rgba(242, 242, 240, 0.62);
    --kz-stairs-transition-line: rgba(242, 242, 240, 0.14);
    --kz-stairs-transition-ink: #f2f2f0;
  }
}
```

A site with its own theme switch puts the same rules under its dark selector instead (`[data-theme="dark"] .kodza-stairs-transition { … }`); 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-stairs-transition.css` next to the page (the Files commands as they are), linked in `<head>`: `<link rel="stylesheet" href="kodza/kodza-stairs-transition.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-stairs-transition.js`, loaded with `<script src="kodza/kodza-stairs-transition.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. A site with its own type sets it once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-font-display`, the display face (headings, big numbers, wordmarks); `--kz-font-display-weight`, its weight; `--kz-font-display-scale`, a multiplier on its sizes (1 by default); `--kz-font-display-tracking`, its letter-spacing; `--kz-font-display-case`, its case (`uppercase`…); `--kz-font-mono`, the monospace face (code, labels, counters). 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">`.
- Corners: a site with its own shapes sets them once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-radius-scale` multiplies every corner (`0` square, `0.5` softer, `1` Kodza).
- 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-stairs-transition.css' -e 'd' -e '}' -e '/^KODZA-JS$/{r kodza/kodza-stairs-transition.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">`. A site with its own type sets it once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-font-display`, the display face (headings, big numbers, wordmarks); `--kz-font-display-weight`, its weight; `--kz-font-display-scale`, a multiplier on its sizes (1 by default); `--kz-font-display-tracking`, its letter-spacing; `--kz-font-display-case`, its case (`uppercase`…); `--kz-font-mono`, the monospace face (code, labels, counters).
- Corners: a site with its own shapes sets them once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-radius-scale` multiplies every corner (`0` square, `0.5` softer, `1` Kodza).
- 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-stairs-transition', $uri . 'kodza-stairs-transition.css', [], filemtime($dir . 'kodza-stairs-transition.css'));
    wp_enqueue_script('kodza-stairs-transition', $uri . 'kodza-stairs-transition.js', [], filemtime($dir . 'kodza-stairs-transition.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>`).
- Paste the CSS and the script on every page (a code snippet plugin, or your theme's footer): every link on the site gets the staircase. Set the variables on html to tune it. Firefox has no transition between two documents yet: it loads the next page without it. Another Kodza page transition on the page takes over.

#### 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-stairs-transition.css` and `assets/kodza-stairs-transition.js` (the Files commands with `-o assets/…`).
- In the section: `{{ 'kodza-stairs-transition.css' | asset_url | stylesheet_tag }}` and `<script src="{{ 'kodza-stairs-transition.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/`): `StairsTransition.tsx` (below), the CSS block under Code saved as `stairs-transition.css` and the JS block as `stairs-transition.js`, both as they are. The HTML block is already inside the component: skip it.
- Use it: `import { StairsTransition } from '@/components/kodza/StairsTransition';` then `<StairsTransition />`. Props: `variant` ("from-top", or several in an array), `vars` (the `--kz-…` variables listed under API, e.g. `vars={{ '--kz-stairs-transition-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 './stairs-transition.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.
- Built from your data (tabs, rows, options)? Add what the markup is made of to the `key`, as plain values (ids, text, numbers). What the visitor changes (the open tab, the sort, what they type) is the script's: follow it with `onClick` or `onInput` on the root, and change it from your code as a visitor would (click the tab; set the field's value, then dispatch a bubbling `input` event). Fields stay uncontrolled (`defaultValue`, `name`).

```tsx
'use client';
// Kodza — Stairs Transition, for React. From https://kodza.dev/components/stairs-transition/
// 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: stairs-transition.css and stairs-transition.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.
// data-kodza-manual keeps the script from starting it on its own: the effect below does, once
// React has hydrated the markup (on a page that streams in, the script can load first).
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './stairs-transition.css';

type Variant = "from-top";

export type StairsTransitionProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "variant" | "vars" | "onPageEngine" | "onPageTransition"> & {
  /** Modifier class(es): .kodza-stairs-transition--from-top */
  variant?: Variant | Variant[];
  /** Settings, as CSS variables on the root */
  vars?: {
    /** the whole staircase, first band up to last band off — default: 1.2s */
    "--kz-stairs-transition-duration"?: string | number;
    /** from one band to the next — default: 0.05s */
    "--kz-stairs-transition-stagger"?: string | number;
    /** default: cubic-bezier(0.76, 0, 0.24, 1) */
    "--kz-stairs-transition-ease"?: string | number;
    /** the bands — default: #0a0a0a */
    "--kz-stairs-transition-ink"?: string | number;
    /** 1: up from the bottom, -1: down from the top — default: 1 */
    "--kz-stairs-transition-way"?: string | number;
    /** the arrival starts as the bands step off it — default: 0.8s */
    "--kz-stairs-transition-enter-delay"?: string | number;
    /** default: cubic-bezier(0.2, 0.8, 0.1, 1) */
    "--kz-stairs-transition-enter-ease"?: string | number;
    /** default: #f2f2f0 */
    "--kz-stairs-transition-bg"?: string | number;
    /** default: #0a0a0a */
    "--kz-stairs-transition-fg"?: string | number;
    /** default: rgba(10, 10, 10, 0.62) */
    "--kz-stairs-transition-muted"?: string | number;
    /** default: rgba(10, 10, 10, 0.12) */
    "--kz-stairs-transition-line"?: string | number;
    /** default: calc(16px * var(--kz-radius-scale, 1)) */
    "--kz-stairs-transition-image-radius"?: string | number;
    /** default: 24px */
    "--kz-stairs-transition-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 StairsTransition({ variant, vars, onPageEngine, onPageTransition, className, style, ...rest }: StairsTransitionProps) {
  const ref = useRef<ComponentRef<'section'>>(null);
  const classes = ["kodza-stairs-transition", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-stairs-transition--${v}`), 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('./stairs-transition.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-stairs-transition="" key={key} {...rest} className={classes} data-kodza-manual style={{ ...vars, ...style } as CSSProperties} ref={ref}>
      <p className="kodza-stairs-transition__label" data-enter="label">Case study · 04 / 12</p>
      <div className="kodza-stairs-transition__split">
        <h1 className="kodza-stairs-transition__title" data-enter="title">The deploy that went out on a Friday</h1>
        <div className="kodza-stairs-transition__intro">
          <p className="kodza-stairs-transition__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-stairs-transition__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-stairs-transition__media">
        <div className="kodza-stairs-transition__frame" data-enter="image">
          <img className="kodza-stairs-transition__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-stairs-transition" data-kodza-stairs-transition>
  <p class="kodza-stairs-transition__label" data-enter="label">Case study · 04 / 12</p>
  <div class="kodza-stairs-transition__split">
    <h1 class="kodza-stairs-transition__title" data-enter="title">The deploy that went out on a Friday</h1>
    <div class="kodza-stairs-transition__intro">
      <p class="kodza-stairs-transition__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-stairs-transition__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-stairs-transition__media">
    <div class="kodza-stairs-transition__frame" data-enter="image">
      <img class="kodza-stairs-transition__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 — Stairs Transition
   A page transition between two real pages (cross-document View Transitions). On a click five
   bands of ink climb over the page you leave one after the other, like a staircase, then step
   off the top in the same order onto the new page, which plays its arrival: the label, the
   title rising line by line, the image settling from a zoom, the rest in a cascade (mark them with
   data-enter="label", "title", "image", or just data-enter). Going back plays it in reverse:
   the staircase starts from the other edge and comes down. Modifier: --from-top. Without the
   API (Firefox) or with reduced motion, navigation stays plain. Tuned with the
   --kz-stairs-transition-* variables, on .kodza-stairs-transition (or on html, for the pages
   without the section). */
.kodza-stairs-transition {
  --kz-stairs-transition-duration: 1.2s; /* the whole staircase, first band up to last band off */
  --kz-stairs-transition-stagger: 0.05s; /* from one band to the next */
  --kz-stairs-transition-ease: cubic-bezier(0.76, 0, 0.24, 1);
  --kz-stairs-transition-ink: #0a0a0a; /* the bands */
  --kz-stairs-transition-way: 1; /* 1: up from the bottom, -1: down from the top */
  --kz-stairs-transition-enter-delay: 0.8s; /* the arrival starts as the bands step off it */
  --kz-stairs-transition-enter-ease: cubic-bezier(0.2, 0.8, 0.1, 1);
  --kz-stairs-transition-bg: #f2f2f0;
  --kz-stairs-transition-fg: #0a0a0a;
  --kz-stairs-transition-muted: rgba(10, 10, 10, 0.62);
  --kz-stairs-transition-line: rgba(10, 10, 10, 0.12);
  --kz-stairs-transition-image-radius: calc(16px * var(--kz-radius-scale, 1));
  --kz-stairs-transition-side: 24px;

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

.kodza-stairs-transition--from-top {
  --kz-stairs-transition-way: -1;
}

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

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

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

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

/* The page you leave drifts a little as the bands climb, and goes once they all cover it... */
:is(html.kodza-stairs-transition-on, html:has(.kodza-stairs-transition):not(.kodza-page-engine))::view-transition-old(root) {
  animation-name: kodza-stairs-transition-leave;
}

/* ...the next one is there when they step off it, and settles as it is uncovered */
:is(html.kodza-stairs-transition-on, html:has(.kodza-stairs-transition):not(.kodza-page-engine))::view-transition-new(root) {
  animation-name: kodza-stairs-transition-arrive;
}

/* Back: the same staircase played backwards, from the other edge and down */
:is(html.kodza-stairs-transition-on, html:has(.kodza-stairs-transition):not(.kodza-page-engine)).kodza-stairs-transition-back::view-transition-old(root) {
  animation-name: kodza-stairs-transition-arrive;
  animation-direction: reverse;
}

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

/* The bands, above everything (the header too: the staircase covers the screen). Each one is
   the same climb, a stagger later than the one on its left; all five cover the page from 47 %
   to 53 % of the duration, when the pages trade places */
html.kodza-stairs-transition-on::view-transition-group(*.kodza-stairs-transition-band) {
  z-index: 50;
  animation: none;
}

html.kodza-stairs-transition-on::view-transition-new(*.kodza-stairs-transition-band) {
  mix-blend-mode: normal;
  animation: kodza-stairs-transition-band calc(var(--kz-stairs-transition-duration, 1.2s) - 4 * var(--kz-stairs-transition-stagger, 0.05s)) var(--kz-stairs-transition-ease, cubic-bezier(0.76, 0, 0.24, 1)) both;
  animation-delay: calc(var(--kz-stairs-transition-step, 0) * var(--kz-stairs-transition-stagger, 0.05s) - var(--kz-stairs-transition-at, 0) * var(--kz-stairs-transition-duration, 1.2s));
}

html.kodza-stairs-transition-on.kodza-stairs-transition-back::view-transition-new(*.kodza-stairs-transition-band) {
  animation-direction: reverse;
}

/* Which step each band is: from the left going, from the right coming back */
html.kodza-stairs-transition-on::view-transition-new(kodza-stairs-transition-band-2) { --kz-stairs-transition-step: 1; }
html.kodza-stairs-transition-on::view-transition-new(kodza-stairs-transition-band-3) { --kz-stairs-transition-step: 2; }
html.kodza-stairs-transition-on::view-transition-new(kodza-stairs-transition-band-4) { --kz-stairs-transition-step: 3; }
html.kodza-stairs-transition-on::view-transition-new(kodza-stairs-transition-band-5) { --kz-stairs-transition-step: 4; }
html.kodza-stairs-transition-on.kodza-stairs-transition-back::view-transition-new(kodza-stairs-transition-band-1) { --kz-stairs-transition-step: 4; }
html.kodza-stairs-transition-on.kodza-stairs-transition-back::view-transition-new(kodza-stairs-transition-band-2) { --kz-stairs-transition-step: 3; }
html.kodza-stairs-transition-on.kodza-stairs-transition-back::view-transition-new(kodza-stairs-transition-band-3) { --kz-stairs-transition-step: 2; }
html.kodza-stairs-transition-on.kodza-stairs-transition-back::view-transition-new(kodza-stairs-transition-band-4) { --kz-stairs-transition-step: 1; }
html.kodza-stairs-transition-on.kodza-stairs-transition-back::view-transition-new(kodza-stairs-transition-band-5) { --kz-stairs-transition-step: 0; }

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

:is(html.kodza-stairs-transition-on, html:has(.kodza-stairs-transition):not(.kodza-page-engine))::view-transition-old(*.kodza-stairs-transition-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-stairs-transition-on, html:has(.kodza-stairs-transition):not(.kodza-page-engine))::view-transition-new(*.kodza-stairs-transition-stay) {
  height: 100%;
  mix-blend-mode: normal;
  animation: none;
}

/* One band: off screen at both ends, it covers its column from 36 % to 64 % of its own climb */
@keyframes kodza-stairs-transition-band {
  0% {
    transform: translateY(calc(100% * var(--kz-stairs-transition-way, 1)));
  }

  36%,
  64% {
    transform: none;
  }

  100% {
    transform: translateY(calc(-100% * var(--kz-stairs-transition-way, 1)));
  }
}

/* The two pages trade places under the bands, at half time, when all five cover the screen */
@keyframes kodza-stairs-transition-leave {
  0% {
    transform: none;
  }

  44% {
    transform: translateY(calc(-4% * var(--kz-stairs-transition-way, 1)));
    opacity: 1;
  }

  50%,
  100% {
    transform: translateY(calc(-4% * var(--kz-stairs-transition-way, 1)));
    opacity: 0;
    animation-timing-function: steps(1, start);
  }
}

@keyframes kodza-stairs-transition-arrive {
  0%,
  50% {
    transform: translateY(calc(6% * var(--kz-stairs-transition-way, 1)));
    opacity: 0;
    animation-timing-function: steps(1, end);
  }

  50.01%,
  56% {
    transform: translateY(calc(6% * var(--kz-stairs-transition-way, 1)));
    opacity: 1;
  }

  100% {
    transform: none;
    opacity: 1;
  }
}

/* The bands themselves: put on the page by the script, drawn only while a transition runs */
.kodza-stairs-transition__stairs {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 2147483000;
  pointer-events: none;
}

html.kodza-stairs-transition-cover:active-view-transition .kodza-stairs-transition__stairs {
  display: flex;
}

.kodza-stairs-transition__band {
  display: block;
  flex: 1 1 0;
  margin-right: -1px; /* each one a pixel over the next: no seam while they cover the page */
  background: var(--kz-stairs-transition-ink, #0a0a0a);
  view-transition-class: kodza-stairs-transition-band;
}

.kodza-stairs-transition__band:last-child {
  margin-right: 0;
}

.kodza-stairs-transition__band:nth-child(1) { view-transition-name: kodza-stairs-transition-band-1; }
.kodza-stairs-transition__band:nth-child(2) { view-transition-name: kodza-stairs-transition-band-2; }
.kodza-stairs-transition__band:nth-child(3) { view-transition-name: kodza-stairs-transition-band-3; }
.kodza-stairs-transition__band:nth-child(4) { view-transition-name: kodza-stairs-transition-band-4; }
.kodza-stairs-transition__band:nth-child(5) { view-transition-name: kodza-stairs-transition-band-5; }

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

@keyframes kodza-stairs-transition-failsafe {
  to {
    opacity: 1;
  }
}

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

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

.kodza-stairs-transition__word {
  display: inline-block;
}

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

.kodza-stairs-transition__label {
  margin-bottom: 24px;
}

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

.kodza-stairs-transition__title {
  margin: 0;
  color: inherit;
  font-family: var(--kz-font-display, inherit);
  font-size: calc(clamp(44px, 6.6vw, 96px) * var(--kz-font-display-scale, 1));
  font-weight: var(--kz-font-display-weight, 600);
  line-height: 0.95;
  letter-spacing: var(--kz-font-display-tracking, -0.055em);
  text-transform: var(--kz-font-display-case, inherit);
  text-wrap: balance;
}

.kodza-stairs-transition__intro {
  display: grid;
  gap: 32px;
  padding-bottom: 6px;
}

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

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

.kodza-stairs-transition__facts > div {
  padding-top: 12px;
  border-top: 1px solid var(--kz-stairs-transition-line);
}

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

.kodza-stairs-transition__media {
  margin: 56px 0 0;
}

.kodza-stairs-transition__frame {
  overflow: hidden;
  aspect-ratio: 21 / 9;
  border-radius: var(--kz-stairs-transition-image-radius);
  background: var(--kz-stairs-transition-line);
}

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

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

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

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

  .kodza-stairs-transition__media {
    margin-top: 40px;
  }

  .kodza-stairs-transition__frame {
    aspect-ratio: 4 / 3;
  }
}

@media (max-width: 480px) {
  .kodza-stairs-transition__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 9e5b4c54e659 — 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-stairs-transition__label:not(#_),
.kodza-stairs-transition__facts dt:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-stairs-transition__facts :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_),
.kodza-stairs-transition__media figcaption:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-stairs-transition__media :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):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-stairs-transition__split:not(#_),
.kodza-stairs-transition__mask:not(#_),
.kodza-stairs-transition__word:not(#_),
.kodza-stairs-transition__intro:not(#_),
.kodza-stairs-transition__frame:not(#_),
.kodza-stairs-transition__facts div:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-stairs-transition__facts :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_),
.kodza-stairs-transition__media span:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-stairs-transition__media :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_) {
  box-sizing: revert;
}

.kodza-stairs-transition__title:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  transition: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-style: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-stairs-transition__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-stairs-transition__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-stairs-transition__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-stairs-transition__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-stairs-transition__facts dd:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-stairs-transition__facts :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):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-stairs-transition__label:not(#_):where(:not([hidden])),
.kodza-stairs-transition__title:not(#_):where(:not([hidden])),
.kodza-stairs-transition__lead:not(#_):where(:not([hidden])),
.kodza-stairs-transition__media:not(#_):where(:not([hidden])),
.kodza-stairs-transition__facts dt:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-stairs-transition__facts :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_):where(:not([hidden])),
.kodza-stairs-transition__facts dd:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-stairs-transition__facts :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_):where(:not([hidden])) {
  display: revert;
}

.kodza-stairs-transition__label:not(#_):where([hidden]),
.kodza-stairs-transition__title:not(#_):where([hidden]),
.kodza-stairs-transition__lead:not(#_):where([hidden]),
.kodza-stairs-transition__media:not(#_):where([hidden]),
.kodza-stairs-transition__facts dt:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-stairs-transition__facts :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_):where([hidden]),
.kodza-stairs-transition__facts dd:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-stairs-transition__facts :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):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-stairs-transition__label:not(#_),
.kodza-stairs-transition__facts dt:not(#_),
.kodza-stairs-transition__media figcaption:not(#_) {
  margin: 0;
  font-family: var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace);
  font-size: 12px;
  font-weight: 400;
  line-height: 1.4;
  letter-spacing: 0;
  color: var(--kz-stairs-transition-muted);
}

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

.kodza-stairs-transition__title:not(#_) {
  margin: 0;
  color: inherit;
  font-family: var(--kz-font-display, inherit);
  font-size: calc(clamp(44px, 6.6vw, 96px) * var(--kz-font-display-scale, 1));
  font-weight: var(--kz-font-display-weight, 600);
  line-height: 0.95;
  letter-spacing: var(--kz-font-display-tracking, -0.055em);
  text-transform: var(--kz-font-display-case, inherit);
}

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

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

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

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

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

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

@media (max-width: 860px) {
  .kodza-stairs-transition__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 — Stairs Transition
// The staircase itself is CSS: between two real pages the browser draws it (cross-document View
// Transitions). The script puts the bands on the page for the transition, gives it its
// direction (back plays it in reverse, from wherever an interrupted staircase was), keeps the
// header in place, plays the arrival of the new page, and runs the same staircase inside one
// page for sites that swap their content (kodza:page-transition).
(() => {
  const SELECTOR = '[data-kodza-stairs-transition]';

  // The two lines that change from one page transition of the family to the next
  const NAME = 'stairs-transition';
  const VARS = ['duration', 'stagger', 'ease', 'ink', 'way', '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 settling from a zoom, 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 lift = 0.56 * ms(style.getPropertyValue(`--kz-${NAME}-duration`), 1200); // the next page starts to show
    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)]);
    // The image is there when the bands uncover it, and settles from a zoom as they step off:
    // it moves with the transition instead of starting once it has gone.
    const image = parts.filter((el) => el.dataset.enter === 'image').flatMap((el) => {
      const media = el.matches('img, video, picture') ? null : el.querySelector('img, video, picture');
      return media ? [add(media, [{ transform: 'scale(1.2)' }, { transform: 'none' }], 1600)] : [];
    });
    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: lifted ? lift - delay : 0 }));
        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) ----------

  // The staircase: five bands of ink, drawn only while a transition runs
  const COVER = `kodza-${NAME}-cover`;
  const BANDS = 5;
  let stairs = null;
  let watching = false;
  const active = () => {
    try {
      return html.matches(':active-view-transition');
    } catch (error) {
      return false;
    }
  };
  function cover() {
    if (!stairs) {
      stairs = document.createElement('div');
      stairs.className = `kodza-${NAME}__stairs`;
      stairs.setAttribute('aria-hidden', 'true');
      for (let i = 0; i < BANDS; i++) {
        const band = document.createElement('span');
        band.className = `kodza-${NAME}__band`;
        stairs.append(band);
      }
      page.signal.addEventListener('abort', () => {
        stairs.remove();
        html.classList.remove(COVER);
      }, { once: true });
    }
    if (!stairs.isConnected) (document.body || html).append(stairs);
    html.classList.add(COVER);
    if (watching) return;
    watching = true;
    // The class goes with the transition: the bands are never drawn on the page itself
    const watch = () => {
      if (active() && !page.signal.aborted) {
        requestAnimationFrame(watch);
        return;
      }
      watching = false;
      html.classList.remove(COVER);
    };
    requestAnimationFrame(watch);
  }
  // Between two documents: the page that lands puts them on before the browser captures it
  window.addEventListener('pagereveal', (event) => {
    if (event.viewTransition && !motion.matches) cover();
  }, { signal: page.signal });
  // Inside one page: once the content is swapped, before the browser captures the new state
  window.addEventListener('kodza:page-transition', (event) => {
    const update = event.detail?.update;
    if (typeof update !== 'function' || motion.matches) return;
    event.detail.update = async () => {
      await update();
      if (active()) cover();
    };
  }, { capture: true, signal: page.signal });

  // 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. 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. Two
  // components on one root (a reveal on a stat card) both start, and stop together.
  const KODZA = Symbol.for('kodza');
  const RUNNING = Symbol.for(`kodza ${SELECTOR}`); // a root this component runs on, for every copy of this script
  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) => {
    // Already running, maybe from another copy of this script. Marked is-ready with no Kodza component
    // running on it, a root is a copy of a started one (a cloned slide): it is left as it is
    if (root[RUNNING] || (root.classList.contains('is-ready') && !root[KODZA])) return;
    // Rendered by React (data-kodza-manual): React starts it, with an init aimed at that markup once
    // it has hydrated it. Any earlier start would change markup React has not taken over yet
    const owner = root.closest('[data-kodza-manual]');
    if (owner && !(scope && owner.contains(scope))) return;
    const controller = new AbortController();
    // Another Kodza component already running on this root: its markup as written is kept, and the
    // two stop together
    const before = root[KODZA];
    const saved = { html: before ? before.html : written(root), attrs: before ? before.attrs : attrsOf(root), stop: () => destroy(root) };
    if (before) before.above = saved;
    instances.set(root, { controller, saved, before });
    root[KODZA] = saved;
    root[RUNNING] = true;
    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.saved.above?.stop(); // a Kodza component started after it on the same root stops first
    // 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[RUNNING];
    // Started on top of another one: that one stops next, and puts the markup back
    if (instance.before) {
      delete instance.before.above;
      root[KODZA] = instance.before;
      return instance.before.stop();
    }
    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).
