atelier

Catalog / Recipe

Page morph

A picture and its title travel from a listing to the page they lead to and back, across two real documents; the page itself crosses by a fade, a slide, an iris or a wipe.

Family
page transition
Status
lab
Used for
Page transitionListingCard gridNavigation
Size
3,695 of 4,000 bytes, gzipped
Frame
Hold at
Skin
Version
move
aspect
reduced
mount(element)

The browser photographs the page being left, lets the page being entered stand above it, and animates between the two. An element named in both documents morphs from its old box to its new one. This recipe names the pair that travels for the one navigation it travels in, and nothing else, and gives the page itself a movement.

Use

Build the file as a classic script and call it from the head of every page, before anything is drawn:

npx esbuild page-morph.ts --bundle --format=iife --global-name=PageMorph --outfile=page-morph.js

<script src="/page-morph.js"></script>
<script>PageMorph.mount(document.documentElement, { move: 2 });</script>

A module script is too late: pagereveal fires before the arriving document’s first render, and a module arrives after it.

Two engines

Where the browser has cross-document view transitions (Chrome, Safari) the navigation is the browser’s own: the old page is photographed, the new one loads, and the recipe only names and types. Where it has same-document transitions alone (Firefox) the recipe does the navigation itself: on a click it fetches the next page and, inside one transition, swaps its body in, sets the title and the language, pushes the address, and runs the new body’s inline scripts. Back and forward swap the same way. A page it cannot fetch, or that is not HTML on this origin, is left to the browser. The movements, the plate, the title and the kept place are the same under both. Without either, every navigation is a plain one.

The swap is a small client-side router, which the research called a different kind of site. It is bounded to that: module and linked scripts in the new body do not run, and a page that depends on them should not be swapped.

What travels is said in the markup. On a listing, inside each link, data-morph="plate" on the picture and data-morph="title" on the heading. On the page a link leads to, the same two on the hero and the title, outside any link. Nothing is named in a stylesheet.

How it moves

Forward. On a click the recipe remembers the link and where it was clicked. When the navigation goes where that link leads, it names the link’s plate and title, and the arriving page names its own. The picture travels from the card to the hero; the title travels with it.

Back. The arriving listing names the row that leads to where the reader came from. A project listed twice morphs from whichever row was clicked, because no row carries a name until a navigation needs it. Back is drawn as back: the page that was left recedes and the one returned to settles in from behind. Back is a traversal, or a navigation to the page before this one.

The page itself, a choice. Fade: the browser’s cross-fade. Slide: the new page rises in. Iris: the new page opens as a circle from where the reader clicked, and back closes the old page to that point. Wipe: top to bottom, and back is bottom to top.

A language switch. A navigation to an address one of the page’s <link rel="alternate" hreflang> elements gives. The leaving page records the first heading on screen and how far down it sits; the twin page scrolls so the same heading sits at the same distance, before its first render. The old text goes quickly, the new comes after it, so two languages are never both legible. The first picture on screen is named on both sides and holds still. A browser with no transitions keeps the place too, after its first render.

Options

Option Default Meaning
move 1 Fade, Slide, Iris or Wipe
duration 320 The page’s movement, in ms
plate 1.15 The travelling picture’s duration, as a factor of the page’s
aspect 0 A picture whose shape changes: cross-fade under cover, the new one only, or contain to cover
title true The title travels with the picture
direction true Back is drawn as back
keepPlace true A language switch keeps the reader’s place
reduced 0 Under reduced motion: no transition, or a plain cross-fade

Versions

Version What it is
Slide The defaults
Fade The browser’s cross-fade, at the recipe’s duration
Iris The page opens from where the reader clicked
Wipe Top to bottom
Quiet Shorter, and the title stays put

Where it must not go

  • A reload, a typed address, another origin. A plain navigation, by design: the browser runs no transition for those.
  • A page whose first render takes more than about four seconds. The transition is skipped. What skips it is a slow first render, not a slow server.
  • A load effect on the same page. The recipe stamps data-arrival="transition" on html when it ran, and a load effect reads that and does not play a second arrival.

What it does when things are missing

Without cross-document view transitions (Firefox), every navigation is a plain one and a language switch still keeps the place. Under reduced motion the transition is skipped, or, by option, a plain cross-fade with no movement. The decision is made in script, through matchMedia, so a host can preview it. destroy() removes the listeners and the style and clears any name.

Learned while building it

  • A blocking head script can opt the document in by itself. Measured in Chrome 155: a <style> with @view-transition { navigation: auto } appended from the head ran the transition on a click, on back, and not on a reload. The recipe needs no stylesheet fragment.
  • A stale record is not a previous page. Reloading leaves the last record in storage; it only counts when its destination is this page.
page-morph.ts485 lines · 3,695 bytes gzipped · no imports
/**
 * page-morph
 *
 * A picture and its title travel from a listing to the page they lead
 * to, and back, across two real documents. The page itself crosses by
 * a fade, a slide, an iris from where the reader clicked, or a wipe. A
 * language switch keeps the reader's place.
 *
 * Copy this file. It imports nothing.
 *
 * It must be in place before the arriving document's first render,
 * because `pagereveal` fires before it. So it is called from a classic,
 * blocking script in the head of every page, not from a module:
 *
 *     npx esbuild page-morph.ts --bundle --format=iife --global-name=PageMorph --outfile=page-morph.js
 *
 *     <script src="/page-morph.js"></script>
 *     <script>PageMorph.mount(document.documentElement, { move: 2 });</script>
 *
 * What travels is said in the markup, with one attribute: on a listing,
 * `data-morph="plate"` on the picture inside each link and
 * `data-morph="title"` on its heading; on the page a link leads to, the
 * same two on the hero and the title, outside any link. Nothing is
 * named in a stylesheet: the pair that travels is named for the one
 * navigation, when it happens, and cleared after it. A project listed
 * twice travels from whichever row was clicked.
 *
 * Two engines. Where the browser has cross-document view transitions
 * (Chrome, Safari) the navigation is the browser's own and the recipe
 * only names and types. Where it has same-document ones alone
 * (Firefox) the recipe fetches the next page, swaps its body in inside
 * one, and keeps the address and the history right; the inline scripts
 * of the new body run, nothing else of it does. Without either, a
 * navigation is a plain one.
 *
 * A language switch is a navigation to an address one of the page's
 * `<link rel="alternate" hreflang>` elements gives. The twin page opens
 * with the same heading the same distance down.
 */

export interface Options {
  /** The page's own movement between two documents: 0 a cross-fade, 1 a slide in the direction of travel, 2 an iris that opens from where the reader clicked, 3 a wipe. */
  move: number;
  /** How long the page's movement takes, in ms. */
  duration: number;
  /** How much longer than the page the travelling picture takes, as a factor. */
  plate: number;
  /** A picture whose shape changes between the two pages, mid-flight: 0 both cropped to the box and cross-faded, 1 the new one alone, 2 the old one fitted inside and the new one cropped. */
  aspect: number;
  /** The title travels with the picture. */
  title: boolean;
  /** Back is drawn as back: the page that was left recedes, the one returned to settles in from behind. */
  direction: boolean;
  /** A language switch keeps the reader's place: the twin page opens with the same heading the same distance down. */
  keepPlace: boolean;
  /** Under reduced motion: 0 no transition at all, 1 a plain cross-fade with no movement. */
  reduced: number;
}

export const DEFAULTS: Options = {
  move: 1,
  duration: 320,
  plate: 1.15,
  aspect: 0,
  title: true,
  direction: true,
  keepPlace: true,
  reduced: 0,
};

/** Named versions. The first is the defaults. */
export const PRESETS: Record<string, Partial<Options>> = {
  Slide: {},
  Fade: { move: 0 },
  Iris: { move: 2 },
  Wipe: { move: 3 },
  Quiet: { duration: 220, title: false },
};

export interface Handle {
  update(options: Partial<Options>): void;
  destroy(): void;
}

/** What the leaving page tells the arriving one, through sessionStorage. */
export interface Handoff {
  from: string;
  to: string | null;
  x: number;
  y: number;
  back: boolean;
  locale: boolean;
  /** On a language switch, the plate on screen to hold still, by index among the document's plates; -1 for none. */
  plate: number;
  heading: { index: number; top: number } | null;
}

const KEY = 'page-morph';
const MOVES = ['fade', 'slide', 'iris', 'wipe'] as const;
const PLATE = 'morph-plate';
const TITLE = 'morph-title';
const EASE = 'cubic-bezier(0.2, 0, 0, 1)';
const NOOP: Handle = { update() {}, destroy() {} };

const clampIndex = (value: unknown, count: number, fallback: number): number =>
  typeof value === 'number' && Number.isFinite(value) ? Math.min(count - 1, Math.max(0, Math.round(value))) : fallback;
const clampNumber = (value: unknown, low: number, high: number, fallback: number): number =>
  typeof value === 'number' && Number.isFinite(value) ? Math.min(high, Math.max(low, value)) : fallback;

export function mergeOptions(base: Options, explicit: Partial<Options>): Options {
  return {
    move: clampIndex(explicit.move, MOVES.length, base.move),
    duration: clampNumber(explicit.duration, 0, 3000, base.duration),
    plate: clampNumber(explicit.plate, 0.5, 3, base.plate),
    aspect: clampIndex(explicit.aspect, 3, base.aspect),
    title: typeof explicit.title === 'boolean' ? explicit.title : base.title,
    direction: typeof explicit.direction === 'boolean' ? explicit.direction : base.direction,
    keepPlace: typeof explicit.keepPlace === 'boolean' ? explicit.keepPlace : base.keepPlace,
    reduced: clampIndex(explicit.reduced, 2, base.reduced),
  };
}

/** Back is a traversal, or a return to the page before this one. */
export function isBack(destination: string | null, previous: string | null, navigationType: string | null): boolean {
  if (navigationType === 'traverse') return true;
  return destination !== null && previous !== null && destination === previous;
}

/** The page before this one, known only from a record that led here. A reload leaves a stale record behind, and that says nothing. */
export function previousOf(record: { from: string; to: string | null } | null, here: string): string | null {
  return record !== null && record.to === here ? record.from : null;
}

export function isLocaleSwitch(destination: string | null, alternates: readonly string[]): boolean {
  return destination !== null && alternates.includes(destination);
}

/** The first box on screen, and how far down it sits. */
export function headingOnScreen(boxes: readonly { top: number; bottom: number }[], viewportHeight: number): { index: number; top: number } | null {
  const index = boxes.findIndex((box) => box.bottom > 0 && box.top < viewportHeight);
  return index < 0 ? null : { index, top: Math.round(boxes[index]?.top ?? 0) };
}

/** Which link leads to an address; the first, if several do. */
export function leadsTo(links: readonly { href: string }[], address: string | null): number {
  return address === null ? -1 : links.findIndex((link) => link.href === address);
}

/**
 * Which engine this browser gets: the browser's own navigation where
 * `pagereveal` exists, a fetched page swapped in where only same-document
 * transitions do, and a plain navigation otherwise.
 */
export function engineFor(has: { pagereveal: boolean; startViewTransition: boolean }): 'document' | 'swap' | 'none' {
  if (has.pagereveal) return 'document';
  return has.startViewTransition ? 'swap' : 'none';
}

/**
 * The stylesheet: the opt-in, the plate, and every movement keyed by
 * its type. The old page and the new one are written as a pair per
 * movement, forward and back; `none` leaves one of them still.
 */
export function styleFor(options: Options): string {
  const D = 'var(--morph-duration)';
  const AT = 'at var(--morph-x) var(--morph-y)';
  const plate = `::view-transition-old(${PLATE}), ::view-transition-new(${PLATE})`;
  const aspect = [
    `${plate} { height: 100%; object-fit: cover; overflow: clip; }`,
    `::view-transition-old(${PLATE}) { display: none; } ::view-transition-new(${PLATE}) { animation: none; height: 100%; object-fit: cover; overflow: clip; }`,
    `${plate} { animation: none; mix-blend-mode: normal; height: 100%; overflow: clip; } ::view-transition-old(${PLATE}) { object-fit: contain; } ::view-transition-new(${PLATE}) { object-fit: cover; }`,
  ][options.aspect];
  // A movement: what the old root does and what the new root does, under
  // these types. A keyframe name alone keeps the duration and ease above.
  const pair = (types: string, old: string, fresh: string): string => {
    const root = types.split(' ').map((type) => `:root:active-view-transition-type(${type})`).join('');
    return `${root}::view-transition-old(root) { ${old} } ${root}::view-transition-new(root) { ${fresh} }`;
  };
  const run = (name: string, time = D): string => `animation: ${name} ${time} ${EASE} both;`;
  return [
    '@view-transition { navigation: auto; }',
    `:root { --morph-duration: ${options.duration}ms; --morph-plate: ${Math.round(options.duration * options.plate)}ms; --morph-x: 50vw; --morph-y: 50vh; --morph-r: 150vmax; }`,
    `::view-transition-group(*), ::view-transition-old(root), ::view-transition-new(root) { animation-duration: ${D}; animation-timing-function: ${EASE}; mix-blend-mode: normal; }`,
    `::view-transition-group(${PLATE}) { animation-duration: var(--morph-plate); z-index: 2; } ::view-transition-group(${TITLE}) { z-index: 3; }`,
    aspect ?? '',
    '@keyframes morph-out { to { opacity: 0; scale: 0.99; } } @keyframes morph-in { from { opacity: 0; translate: 0 20px; } }',
    '@keyframes morph-dismiss { to { opacity: 0; scale: 1.03; } } @keyframes morph-restore { from { opacity: 0; scale: 0.985; } }',
    `@keyframes morph-iris-open { from { clip-path: circle(0 ${AT}); } to { clip-path: circle(var(--morph-r) ${AT}); } }`,
    `@keyframes morph-iris-close { from { clip-path: circle(var(--morph-r) ${AT}); } to { clip-path: circle(0 ${AT}); } }`,
    '@keyframes morph-wipe-down { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0); } } @keyframes morph-wipe-up { from { clip-path: inset(0); } to { clip-path: inset(100% 0 0 0); } }',
    '@keyframes morph-gone { to { opacity: 0; } } @keyframes morph-here { from { opacity: 0; } }',
    pair('morph-slide', 'animation-name: morph-out;', 'animation-name: morph-in;'),
    pair('morph-slide morph-back', 'animation-name: morph-dismiss; z-index: 1;', 'animation-name: morph-restore;'),
    pair('morph-iris', 'animation: none;', 'animation-name: morph-iris-open;'),
    pair('morph-iris morph-back', `${run('morph-iris-close')} z-index: 1;`, 'animation: none;'),
    pair('morph-wipe', 'animation: none;', 'animation-name: morph-wipe-down;'),
    pair('morph-wipe morph-back', `${run('morph-wipe-up')} z-index: 1;`, 'animation: none;'),
    pair('morph-locale', 'animation: morph-gone 90ms linear both;', 'animation: morph-here 140ms 90ms linear both;'),
    `:root:active-view-transition-type(morph-reduced)::view-transition-group(*) { animation-name: none; }`,
    pair('morph-reduced', `animation: morph-gone ${D} linear both;`, `animation: morph-here ${D} linear both;`),
  ].join('\n');
}

/** One mount per document. A second replaces the first. */
const mounted = new WeakMap<Document, Handle>();

export function mount(element: HTMLElement | null, options: Partial<Options> = {}): Handle {
  if (!element || typeof sessionStorage === 'undefined') return NOOP;
  const doc = element.ownerDocument;
  const win = doc.defaultView;
  if (!win || !doc.documentElement) return NOOP;
  mounted.get(doc)?.destroy();

  let current = mergeOptions(DEFAULTS, options);
  // The one place motion is decided, so a host can override matchMedia.
  const reduce = (): boolean => typeof win.matchMedia === 'function' && win.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const engine = engineFor({ pagereveal: typeof (win as { PageRevealEvent?: unknown }).PageRevealEvent === 'function', startViewTransition: typeof doc.startViewTransition === 'function' });
  const style = doc.createElement('style');
  style.dataset.pageMorph = '';
  style.textContent = styleFor(current);
  (doc.head ?? doc.documentElement).appendChild(style);

  const read = (): Handoff | null => {
    try {
      const raw = sessionStorage.getItem(KEY);
      return raw === null ? null : (JSON.parse(raw) as Handoff);
    } catch {
      return null;
    }
  };
  const write = (record: Handoff): void => {
    try {
      sessionStorage.setItem(KEY, JSON.stringify(record));
    } catch {
      // Nothing travels, and the navigation is a plain one.
    }
  };
  const named: HTMLElement[] = [];
  const name = (target: Element | null | undefined, value: string): void => {
    if (!(target instanceof HTMLElement)) return;
    target.style.setProperty('view-transition-name', value);
    named.push(target);
  };
  const clear = (): void => {
    for (const target of named.splice(0)) target.style.removeProperty('view-transition-name');
  };
  const pair = (scope: ParentNode): void => {
    name(scope.querySelector('[data-morph="plate"]'), PLATE);
    if (current.title) name(scope.querySelector('[data-morph="title"]'), TITLE);
  };
  const plates = (): HTMLElement[] => Array.from(doc.querySelectorAll<HTMLElement>('[data-morph="plate"]'));
  const headings = (): HTMLElement[] => Array.from(doc.querySelectorAll<HTMLElement>('h1, h2, h3'));
  const alternates = (): string[] => Array.from(doc.querySelectorAll<HTMLLinkElement>('link[rel~="alternate"][hreflang]')).map((link) => link.href);
  const boxes = (list: HTMLElement[]): DOMRect[] => list.map((item) => item.getBoundingClientRect());

  let clicked: { href: string; x: number; y: number; link: HTMLAnchorElement } | null = null;
  // The address of the page this recipe knows itself to be on. The same
  // as the browser's, except on a pop, when the browser has moved on
  // before the recipe hears of it.
  let here = win.location.href;
  let placed = false;
  const keep = (heading: { index: number; top: number }): void => {
    const target = headings()[heading.index];
    if (!target || placed) return;
    placed = true;
    win.scrollTo({ top: target.getBoundingClientRect().top + win.scrollY - heading.top, behavior: 'instant' as ScrollBehavior });
  };

  /** What the leaving page knows, as a record for the arriving one. */
  const recordFor = (to: string | null, navigationType: string | null): Handoff => {
    const previous = read();
    const from = here;
    const locale = isLocaleSwitch(to, alternates());
    const plateOnScreen = locale ? headingOnScreen(boxes(plates()), win.innerHeight) : null;
    return {
      from,
      to,
      x: clicked?.x ?? previous?.x ?? 0,
      y: clicked?.y ?? previous?.y ?? 0,
      back: current.direction && isBack(to, previousOf(previous, from), navigationType),
      locale,
      plate: plateOnScreen ? plateOnScreen.index : -1,
      heading: locale && current.keepPlace ? headingOnScreen(boxes(headings()), win.innerHeight) : null,
    };
  };
  /** The leaving side: the pair that travels, named on this page. */
  const leave = (record: Handoff): void => {
    if (record.locale) name(plates()[record.plate], PLATE);
    else if (clicked && clicked.href === record.to) pair(clicked.link);
  };
  /** The transition's types, from the record. */
  const typesFor = (record: Handoff | null): string[] => {
    if (reduce()) return ['morph-reduced'];
    const types = [`morph-${MOVES[current.move] ?? 'fade'}`];
    if (record?.back) types.push('morph-back');
    if (record?.locale) types.push('morph-locale');
    return types;
  };
  /** The arriving side: the stamp, the click point, and the pair named on this page. */
  const arrive = (record: Handoff | null): void => {
    // One arrival per page: a load effect reads this and does not play.
    doc.documentElement.dataset.arrival = 'transition';
    const root = doc.documentElement.style;
    const x = record?.x ?? win.innerWidth / 2;
    const y = record?.y ?? win.innerHeight / 2;
    root.setProperty('--morph-x', `${x}px`);
    root.setProperty('--morph-y', `${y}px`);
    root.setProperty('--morph-r', `${Math.ceil(Math.hypot(Math.max(x, win.innerWidth - x), Math.max(y, win.innerHeight - y)))}px`);
    if (record?.locale) {
      name(plates()[record.plate], PLATE);
    } else if (plates().some((plate) => plate.closest('a[href]') === null)) {
      // A page of its own: its hero and its title.
      pair(doc);
    } else {
      // A listing: the row that leads back to where the reader came from.
      const links = Array.from(doc.querySelectorAll<HTMLAnchorElement>('a[href]')).filter((link) => link.querySelector('[data-morph="plate"]'));
      const link = links[leadsTo(links, record?.from ?? null)];
      if (link) pair(link);
    }
  };

  // --- the swap: the next page fetched and put in place of this one ------
  let swapping = false;
  const fetchPage = async (href: string): Promise<Document | null> => {
    try {
      // Fetched afresh each time, never kept: a page read while signed in
      // must not come back after signing out. The browser's own cache
      // applies as it would to any navigation. Only HTML from this origin
      // is swapped in; anything else is the browser's to navigate to.
      const response = await win.fetch(href, { headers: { Accept: 'text/html' } });
      if (!response.ok || !(response.headers.get('content-type') ?? '').includes('text/html')) return null;
      if (new URL(response.url, href).origin !== win.location.origin) return null;
      return new DOMParser().parseFromString(await response.text(), 'text/html');
    } catch {
      return null;
    }
  };
  const swap = async (href: string, record: Handoff, push: boolean): Promise<void> => {
    if (swapping) return;
    swapping = true;
    const next = await fetchPage(href);
    if (!next) {
      // Not a page this can swap: the browser navigates.
      swapping = false;
      win.location.assign(href);
      return;
    }
    const change = (): void => {
      if (push) win.history.pushState({ pageMorph: true }, '', href);
      doc.title = next.title;
      doc.documentElement.lang = next.documentElement.lang;
      doc.body.replaceChildren(...Array.from(next.body.childNodes, (node) => doc.importNode(node, true)));
      // The new body's inline scripts run, as they would have on a load;
      // a module or a linked script is the page's own concern.
      for (const old of Array.from(doc.body.querySelectorAll('script'))) {
        if (old.src || (old.type !== '' && old.type !== 'text/javascript')) continue;
        const fresh = doc.createElement('script');
        fresh.textContent = old.textContent;
        old.replaceWith(fresh);
      }
      win.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior });
      placed = false;
      if (record.locale && record.heading && current.keepPlace) keep(record.heading);
      arrive(record);
      here = win.location.href;
      win.dispatchEvent(new Event('page-morph:swapped'));
    };
    const done = (): void => {
      clear();
      swapping = false;
    };
    if ((reduce() && current.reduced === 0) || typeof doc.startViewTransition !== 'function') {
      change();
      done();
      return;
    }
    leave(record);
    let transition: ViewTransition;
    try {
      // Taken from the document at the call, not at mount: a host may
      // wrap it after this script ran.
      transition = doc.startViewTransition(change);
    } catch {
      change();
      done();
      return;
    }
    for (const type of typesFor(record)) transition.types.add(type);
    transition.finished.then(done, done);
  };

  const onClick = (event: MouseEvent): void => {
    const link = (event.target as Element | null)?.closest?.('a[href]');
    if (!(link instanceof HTMLAnchorElement) || event.defaultPrevented || event.button !== 0) return;
    if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
    if (link.origin !== win.location.origin || (link.target !== '' && link.target !== '_self') || link.hasAttribute('download')) return;
    clicked = { href: link.href, x: event.clientX, y: event.clientY, link };
    // A language switch saves the place here, so a browser with no
    // transitions, and no pageswap, keeps it too.
    if (current.keepPlace && isLocaleSwitch(link.href, alternates())) {
      const plate = headingOnScreen(boxes(plates()), win.innerHeight);
      write({ from: win.location.href, to: link.href, x: event.clientX, y: event.clientY, back: false, locale: true, plate: plate ? plate.index : -1, heading: headingOnScreen(boxes(headings()), win.innerHeight) });
    }
    if (engine !== 'swap' || swapping) return;
    // A link to a place on this page is the browser's.
    if (link.pathname === win.location.pathname && link.search === win.location.search && link.hash !== '') return;
    event.preventDefault();
    const record = recordFor(link.href, null);
    write(record);
    void swap(link.href, record, true);
  };
  const onSwap = (event: Event): void => {
    const { viewTransition, activation } = event as PageSwapEvent;
    const record = recordFor(clicked?.href ?? activation?.entry?.url ?? null, activation?.navigationType ?? null);
    write(record);
    if (!viewTransition) return;
    if (reduce() && current.reduced === 0) {
      viewTransition.skipTransition();
      return;
    }
    leave(record);
    viewTransition.finished.then(clear, clear);
  };
  const onReveal = (event: Event): void => {
    const { viewTransition } = event as PageRevealEvent;
    const record = read();
    const arrived = record !== null && record.to === win.location.href ? record : null;
    if (arrived?.locale && arrived.heading && current.keepPlace) keep(arrived.heading);
    if (!viewTransition) return;
    if (reduce() && current.reduced === 0) {
      viewTransition.skipTransition();
      return;
    }
    for (const type of typesFor(arrived)) viewTransition.types.add(type);
    arrive(arrived);
    viewTransition.finished.then(clear, clear);
  };
  // Back and forward, when the pages were swapped in: the page at the
  // address the browser went to is swapped in the same way, as back.
  const onPop = (): void => {
    if (engine !== 'swap') return;
    clicked = null;
    const record = recordFor(win.location.href, 'traverse');
    write(record);
    void swap(win.location.href, record, false);
  };
  // Back from the cache brings a page back as it was left: with no names.
  const onShow = (): void => clear();
  // No pagereveal here (Firefox): the kept place is restored once the
  // headings exist, after the first render, as a jump.
  const onReady = (): void => {
    const record = read();
    if (record?.locale && record.heading && record.to === win.location.href && current.keepPlace) keep(record.heading);
  };

  doc.addEventListener('click', onClick, true);
  win.addEventListener('pageswap', onSwap);
  win.addEventListener('pagereveal', onReveal);
  win.addEventListener('popstate', onPop);
  win.addEventListener('pageshow', onShow);
  if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', onReady, { once: true });

  let destroyed = false;
  const handle: Handle = {
    update(next) {
      current = mergeOptions(current, next);
      style.textContent = styleFor(current);
    },
    destroy() {
      if (destroyed) return;
      destroyed = true;
      doc.removeEventListener('click', onClick, true);
      win.removeEventListener('pageswap', onSwap);
      win.removeEventListener('pagereveal', onReveal);
      win.removeEventListener('popstate', onPop);
      win.removeEventListener('pageshow', onShow);
      doc.removeEventListener('DOMContentLoaded', onReady);
      clear();
      style.remove();
      if (mounted.get(doc) === handle) mounted.delete(doc);
    },
  };
  mounted.set(doc, handle);
  return handle;
}