atelier

Catalog / Recipe

Gallery open

A thumbnail opens into the picture itself, in place, and closes back into its frame: a same-document view transition into a dialog.

Family
page transition
Status
lab
Used for
GalleryFigure
Size
2,199 of 2,300 bytes, gzipped
Frame
Hold at
Skin
Version
aspect
mount(element)

The other way a picture travels: not to another page, but to the front of this one. The thumbnail is named, the document changes (a dialog opens with the full picture, decoded), the picture is named, and the browser morphs one box into the other. Closing runs the same thing backwards.

Use

import { mount } from './gallery-open';

mount(document.querySelector('.gallery'));

Mount it on the gallery. Every link inside it that holds an img is an opener. The link leads to the picture file or to a page, which is what it does with no script: the rest state is the link.

How it moves

On a click the thumbnail is named open; inside the transition’s callback the big picture takes the link’s address (or the thumbnail’s own source when the link leads to a page), is decoded so the new state is never an empty box, takes the name, and the dialog is shown. The figure’s caption travels too, by option. Closing: Escape, the close button, a click on the ground, or the browser’s back when history is on, which adds one entry per opening. The names are set for the one change and cleared when it lands.

The dialog brings the focus move, the trap and the return to the thumbnail by itself, which is why it is a dialog and not a div.

Options

Option Default Meaning
duration 360 How long the opening takes, in ms
aspect 0 A picture whose shape changes: cross-fade under cover, the new one only, or contain to cover
backdrop 0.82 How dark the ground behind the picture is
caption true The figure’s caption travels with the picture
history true Opening adds a history entry, so back closes it
scoped false The element-scoped transition where the browser has it, so the rest of the page stays live. Chromium only

Versions

Version What it is
Open The defaults
Plain No history entry, no caption
Quick Shorter

Where it must not go

  • A listing. A card that opens in place instead of leading to its page takes the reader nowhere.
  • A picture the reader must compare with its neighbours. Opening one hides the rest.

What it does when things are missing

Without startViewTransition the dialog opens without the morph. Under reduced motion, the same. Without dialog support the recipe does nothing and the link opens the picture. Escape during the opening is taken after it lands, so the dialog ends closed and nothing stays named. destroy() closes and removes the dialog, removes the style and the listeners, and clears any name.

Learned while building it

  • A close asked for mid-flight has to wait. Escape while the opening transition runs would start a second transition on a document that is already in one; the browser would skip one of them and leave a name behind. The request is kept and run when the first lands.
gallery-open.ts310 lines · 2,199 bytes gzipped · no imports
/**
 * gallery-open
 *
 * A thumbnail opens into the picture itself, in place, and closes back
 * into its frame: a same-document view transition into a `<dialog>`.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './gallery-open';
 *     const handle = mount(document.querySelector('.gallery'));
 *     // later
 *     handle.destroy();
 *
 * Every link inside the gallery that holds an `img` is an opener. The
 * link leads to the picture file or to a page, which is what it does
 * with no script at all. With script the picture opens here: named,
 * decoded, then shown in a dialog the recipe owns, which brings the
 * focus move, the trap, Escape and the return by itself.
 */

export interface Options {
  /** How long the opening takes, in ms. */
  duration: number;
  /** A picture whose shape changes on the way, 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;
  /** How dark the ground behind the opened picture is, 0 to 1. */
  backdrop: number;
  /** The figure's caption travels with the picture. */
  caption: boolean;
  /** Opening adds a history entry, so the browser's back closes it. */
  history: boolean;
  /** Use the element-scoped transition where the browser has one, so the rest of the page stays live while it runs. Chromium only; elsewhere the document's. */
  scoped: boolean;
}

export const DEFAULTS: Options = {
  duration: 360,
  aspect: 0,
  backdrop: 0.82,
  caption: true,
  history: true,
  scoped: false,
};

/** Named versions. The first is the defaults. */
export const PRESETS: Record<string, Partial<Options>> = {
  Open: {},
  Plain: { history: false, caption: false },
  Quick: { duration: 240 },
};

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

interface Starter {
  startViewTransition(callback: () => void | Promise<void>): ViewTransition;
}

const NAME = 'open';
const CAPTION = 'open-caption';
const NOOP: Handle = { update() {}, destroy() {} };

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;
const flag = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback);

export function mergeOptions(base: Options, explicit: Partial<Options>): Options {
  return {
    duration: clampNumber(explicit.duration, 0, 3000, base.duration),
    aspect: Math.round(clampNumber(explicit.aspect, 0, 2, base.aspect)),
    backdrop: clampNumber(explicit.backdrop, 0, 1, base.backdrop),
    caption: flag(explicit.caption, base.caption),
    history: flag(explicit.history, base.history),
    scoped: flag(explicit.scoped, base.scoped),
  };
}

/** Whether an address is a picture file, by its path. */
export function isPicture(href: string): boolean {
  let path = href;
  try {
    path = new URL(href, 'http://x/').pathname;
  } catch {
    // Judged as written.
  }
  return /\.(png|jpe?g|gif|webp|avif|svg)$/i.test(path);
}

/** The link that holds the picture clicked on, if the click was inside the gallery on one. */
export function openerOf(target: EventTarget | null, root: Element): HTMLAnchorElement | null {
  const link = target instanceof Element ? target.closest('a[href]') : null;
  if (!(link instanceof HTMLAnchorElement) || !root.contains(link) || !link.querySelector('img')) return null;
  return link;
}

export function styleFor(options: Options): string {
  const both = `::view-transition-old(${NAME}), ::view-transition-new(${NAME})`;
  const aspect = [
    `${both} { height: 100%; object-fit: cover; overflow: clip; }`,
    `::view-transition-old(${NAME}) { display: none; } ::view-transition-new(${NAME}) { animation: none; height: 100%; object-fit: cover; overflow: clip; }`,
    `${both} { animation: none; mix-blend-mode: normal; height: 100%; overflow: clip; } ::view-transition-old(${NAME}) { object-fit: contain; } ::view-transition-new(${NAME}) { object-fit: cover; }`,
  ][options.aspect];
  return [
    `::view-transition-group(*), ::view-transition-old(root), ::view-transition-new(root) { animation-duration: ${options.duration}ms; animation-timing-function: cubic-bezier(0.2, 0, 0, 1); }`,
    `::view-transition-group(${NAME}) { z-index: 2; } ::view-transition-group(${CAPTION}) { z-index: 3; }`,
    aspect ?? '',
    `dialog[data-gallery-open]::backdrop { background: rgb(0 0 0 / ${options.backdrop}); }`,
  ].join('\n');
}

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

export function mount(element: HTMLElement | null, options: Partial<Options> = {}): Handle {
  if (!element || typeof HTMLDialogElement === 'undefined') return NOOP;
  const doc = element.ownerDocument;
  const win = doc.defaultView;
  if (!win || !doc.body) return NOOP;
  mounted.get(element)?.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 style = doc.createElement('style');
  style.dataset.galleryOpen = '';
  style.textContent = styleFor(current);
  doc.head.appendChild(style);

  const dialog = doc.createElement('dialog');
  dialog.dataset.galleryOpen = '';
  dialog.setAttribute('aria-label', 'Picture');
  dialog.style.cssText = 'border:0;padding:0;max-width:96vw;max-height:96vh;background:transparent;overflow:visible;color:#fff';
  const figure = doc.createElement('figure');
  figure.style.cssText = 'margin:0;display:grid;gap:.5rem;justify-items:center';
  const big = doc.createElement('img');
  big.style.cssText = 'display:block;max-width:96vw;max-height:86vh;width:auto;height:auto';
  const words = doc.createElement('figcaption');
  words.style.cssText = 'margin:0;text-align:center;font:.9rem/1.4 system-ui,sans-serif';
  const close = doc.createElement('button');
  close.type = 'button';
  close.setAttribute('aria-label', 'Close');
  close.textContent = '×';
  close.style.cssText = 'position:fixed;top:1rem;right:1rem;width:2.5rem;height:2.5rem;border:0;border-radius:50%;background:rgb(255 255 255 / .9);color:#000;font:1.4rem/1 system-ui,sans-serif;cursor:pointer';
  figure.append(big, words);
  dialog.append(figure, close);
  doc.body.appendChild(dialog);

  let opened: HTMLAnchorElement | null = null;
  let busy = false;
  let pendingClose = false;
  let pushed = false;
  const named: HTMLElement[] = [];
  const name = (target: Element | null, 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 captionOf = (link: HTMLAnchorElement): HTMLElement | null => link.closest('figure')?.querySelector('figcaption') ?? null;
  const starter = (): Starter | null => {
    if (reduce()) return null;
    const scoped = element as unknown as Partial<Starter>;
    if (current.scoped && typeof scoped.startViewTransition === 'function') return scoped as Starter;
    return typeof doc.startViewTransition === 'function' ? (doc as unknown as Starter) : null;
  };
  const done = (): void => {
    clear();
    busy = false;
    if (pendingClose) {
      pendingClose = false;
      leave();
    }
  };
  // One change, inside a transition where there is one: name the old
  // state, change, name the new state, and clear both when it lands.
  const run = (prepare: () => void, change: () => void | Promise<void>): void => {
    const engine = starter();
    if (!engine) {
      void Promise.resolve(change()).catch(() => {});
      return;
    }
    busy = true;
    prepare();
    let transition: ViewTransition;
    try {
      transition = engine.startViewTransition(change);
    } catch {
      done();
      void Promise.resolve(change()).catch(() => {});
      return;
    }
    transition.finished.then(done, done);
  };

  const open = (link: HTMLAnchorElement): void => {
    if (busy || dialog.open) return;
    const thumb = link.querySelector('img');
    if (!thumb) return;
    opened = link;
    run(
      () => {
        name(thumb, NAME);
        if (current.caption) name(captionOf(link), CAPTION);
      },
      async () => {
        clear();
        big.src = isPicture(link.href) ? link.href : thumb.currentSrc || thumb.src;
        big.alt = thumb.alt;
        words.textContent = captionOf(link)?.textContent ?? '';
        words.hidden = !current.caption || words.textContent === '';
        try {
          await big.decode();
        } catch {
          // Shown as it comes, then.
        }
        name(big, NAME);
        if (current.caption && !words.hidden) name(words, CAPTION);
        if (!dialog.open) dialog.showModal();
        if (current.history && !pushed) {
          win.history.pushState({ galleryOpen: true }, '');
          pushed = true;
        }
      },
    );
  };
  const shut = (): void => {
    if (busy || !dialog.open) return;
    const link = opened;
    const thumb = link?.querySelector('img') ?? null;
    run(
      () => {
        name(big, NAME);
        if (!words.hidden) name(words, CAPTION);
      },
      () => {
        clear();
        dialog.close();
        name(thumb, NAME);
        if (link && !words.hidden) name(captionOf(link), CAPTION);
        opened = null;
      },
    );
  };
  // Leaving goes through history when opening did, so back and Escape
  // end in the same place; while a transition runs, it waits its turn.
  const leave = (): void => {
    if (busy) {
      pendingClose = true;
      return;
    }
    if (pushed) win.history.back();
    else shut();
  };

  const onClick = (event: MouseEvent): void => {
    if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
    const link = openerOf(event.target, element);
    if (!link) return;
    event.preventDefault();
    open(link);
  };
  const onCancel = (event: Event): void => {
    event.preventDefault();
    leave();
  };
  const onBackdrop = (event: MouseEvent): void => {
    if (event.target === dialog) leave();
  };
  const onPop = (): void => {
    if (!pushed) return;
    pushed = false;
    shut();
  };

  element.addEventListener('click', onClick);
  dialog.addEventListener('cancel', onCancel);
  dialog.addEventListener('click', onBackdrop);
  close.addEventListener('click', leave);
  win.addEventListener('popstate', onPop);

  let destroyed = false;
  const handle: Handle = {
    update(next) {
      current = mergeOptions(current, next);
      style.textContent = styleFor(current);
    },
    destroy() {
      if (destroyed) return;
      destroyed = true;
      element.removeEventListener('click', onClick);
      dialog.removeEventListener('cancel', onCancel);
      dialog.removeEventListener('click', onBackdrop);
      close.removeEventListener('click', leave);
      win.removeEventListener('popstate', onPop);
      clear();
      pushed = false;
      if (dialog.open) dialog.close();
      dialog.remove();
      style.remove();
      if (mounted.get(element) === handle) mounted.delete(element);
    },
  };
  mounted.set(element, handle);
  return handle;
}