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
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
*
* 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;
}