Skip to content

Instantly share code, notes, and snippets.

@RhysSullivan
Created October 26, 2026 02:58
Show Gist options
  • Select an option

  • Save RhysSullivan/4d2ed65cffdb158ff71d098992e0d47d to your computer and use it in GitHub Desktop.

Select an option

Save RhysSullivan/4d2ed65cffdb158ff71d098992e0d47d to your computer and use it in GitHub Desktop.
Van Gogh Museum: faster page transitions (Chrome extension)

Van Gogh Museum: faster page transitions

A small Chrome extension (Manifest V3, no build step) that speeds up page changes on vangoghmuseum.nl and makes paintings grow out of, and shrink back into, their tiles in the collection grid.

Install

  1. Download the files in this gist into one folder (manifest.json, fast.js, shared.js, fast.css).
  2. Open chrome://extensions and turn on Developer mode.
  3. Click Load unpacked and choose the folder.
  4. Reload any open vangoghmuseum.nl tab.

It runs only on https://www.vangoghmuseum.nl/* and asks for no other permissions.

Why the site feels slow

The site swaps pages in place: on a link click it fetches the next page's HTML, then plays a Vue transition. Measured on a cached page, the click-to-settled time was about 0.9 s, almost all of it animation:

  • The old page fades out for 400 ms, then the new page fades in for another 400 ms. The transition is declared on every property, so the wait runs to the 400 ms slide even though the fade finishes at 200 ms.
  • The fade-out starts only after the HTML has arrived. Pages the CDN has not cached take about a second to come back from the server, and that wait comes first.
  • An artwork's image then plays a 500 ms reveal from the top-right corner, and the page background changes colour over 600 ms.

Changes

Timing (fast.css)

  • Page fade-out 400 ms → 120 ms, fade-in 400 ms → 180 ms; the slide on leaving is removed.
  • Background colour change 600 ms → 200 ms, so the new page doesn't appear over a half-way colour.
  • Artwork header image: the corner reveal becomes a 200 ms fade; its buttons no longer wait 1 s.
  • Grid thumbnails fade in over 150 ms instead of 400 ms.
  • The old page dims as soon as a link is clicked, as feedback while the next page loads.
  • The site's own reduced-motion handling is left as it is.

Loading (fast.js)

  • When the pointer rests on a link for 65 ms (or presses it), the next page's HTML is fetched, and the site's router is handed that same response when it asks for it.
  • Fixes clicks that became full page reloads: the router treats Cmd/Ctrl as held until it sees the key go up on the page, so after Cmd-Tab, Cmd-L or another browser shortcut every click reloaded the whole page. The extension releases it when the window loses focus or a click comes without the modifier.

Painting transition (shared.js)

  • Clicking a painting lifts a copy of it out of its tile and grows it into the artwork page's image. Leaving the artwork (back button or a link) shrinks it back into its tile when the grid shows that tile; otherwise it fades out. It is done with a FLIP animation on an overlay, since the site controls the page swap itself.
  • The artwork page lays out its image only after the large file has loaded, so that file starts downloading when the pointer rests on a tile for 150 ms. The preload asks for the same candidates as the page, so the page finds it in the cache.
  • If the pointer is over the tile it lands on, it lands at the tile's hover zoom, so the tile doesn't shrink and grow again afterwards.
  • Works for prints (/prints/collection/…) and the Dutch site (/nl/collectie/…).
  • Skipped when reduced motion is on.

Result

Collection grid to an artwork, measured in the browser:

Before After
Cached page ~0.9 s ~0.4 s
Uncached page ~2 s ~1.2–1.35 s

What remains on uncached pages is the server's response time.

Notes

  • It depends on the site's current markup and class names (.page-transition-fade-*, .art-object-header-image, .collection-art-object-wrapper) and on the router's page-transition-* events, so a site update can break it.
  • Hovering preloads pages and images you may not open, so it uses some extra bandwidth.
/*
* The site's page swap is a Vue out-in transition with `transition: .4s ...`
* on every property, so the old page fades out for 400 ms (its opacity is done
* after 200 ms; the rest is an invisible slide) and only then does the new page
* fade in for another 400 ms. Shorten both halves and drop the leave slide.
* The site's own reduced-motion rules already remove the animation, so leave
* those alone.
*/
@media (prefers-reduced-motion: no-preference) {
main.page-transition > .page-transition-fade-leave-active {
transition: opacity 120ms linear !important;
}
main.page-transition > .page-transition-fade-leave-to {
transform: none !important;
}
main.page-transition > .page-transition-fade-enter-active {
transition: opacity 180ms linear, transform 180ms cubic-bezier(.23, 1, .32, 1) !important;
}
/* Dim the old page as soon as a link is clicked, while its HTML downloads. */
html.vgm-fast-loading main.page-transition > :not(.page-transition-fade-leave-to):not(.page-transition-fade-enter-active) {
opacity: .5;
transition: opacity 150ms linear;
}
/*
* Each page sets its own background colour, and the site fades to it over
* 600 ms. With the faster swap the new page would show up over a half-way
* colour, so change it as fast as the page fades in.
*/
body,
.collection-list-grid-slider-sticky,
.main-navigation.has-open-screen,
.list-filters-filter-window,
.calendar-list-filters {
transition-duration: 200ms !important;
}
/*
* An artwork's header image enters with a 500 ms clip that opens from the
* top-right corner while the image zooms out, and its buttons wait a second
* before sliding in. Next to the faster swap that reads as the image jumping
* in from the corner, so fade it in with the page instead.
*/
.art-object-header .image-loaded .art-object-header-image {
animation: FadeIn 200ms linear forwards !important;
}
.art-object-header .image-loaded .art-object-header-image img {
animation: none !important;
}
.art-object-header .image-loaded .art-object-header-buttons {
animation-duration: 300ms !important;
animation-delay: 200ms !important;
}
/* Grid thumbnails fade in over 400 ms each time the grid is shown again. */
.collection-art-object-item-image.lazy-image {
transition: opacity 150ms linear, transform .4s cubic-bezier(.23, 1, .32, 1) !important;
}
}
/*
* The painting's flight between its grid tile and the artwork page (shared.js).
* While it flies, the copy stands in for the real images at both ends, and the
* new page enters without its slide so the landing spot is where it stays.
*/
.vgm-flight {
position: fixed;
z-index: 9;
margin: 0;
pointer-events: none;
transform-origin: 0 0;
}
.vgm-flight > img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
html.vgm-flying main.page-transition > * {
transform: none !important;
}
html.vgm-flying .art-object-header-image,
html.vgm-flying img[data-vgm-landed] {
visibility: hidden;
}
.art-object-header-image:has(img[data-vgm-landed]),
img[data-vgm-landed] {
animation: none !important;
opacity: 1 !important;
transition: none !important;
}
/* A tile the painting lands on while the pointer is over it (shared.js). */
.collection-art-object-item-single-image[data-vgm-hover] {
transform: scale(var(--hover-scale)) !important;
transition: none !important;
}
// Runs in the page's own world so the site's router uses the patched fetch.
//
// The router intercepts same-site link clicks and calls `fetch(link.href)` for
// the next page's HTML, which takes about a second whenever the CDN has no
// copy. Start that request when the pointer rests on a link (or presses it)
// and hand the router the same response, so it no longer waits from the click.
(() => {
const HOVER_DELAY_MS = 65;
const PREFETCH_TTL_MS = 30_000;
const nativeFetch = window.fetch.bind(window);
const prefetched = new Map(); // href -> { response: Promise<Response>, startedAt }
let hoverTimer = 0;
// Mirrors the router's own rule for which links it intercepts.
const routedHref = (target) => {
const link = target instanceof Element ? target.closest("a[href]") : null;
if (!link || link.hasAttribute("download") || link.target === "_blank") return null;
if (link.host !== location.host) return null;
const withoutHash = link.href.replace(link.hash, "");
if (withoutHash === location.href.replace(location.hash, "")) return null;
return link.href;
};
const prefetch = (href) => {
const entry = prefetched.get(href);
if (entry && Date.now() - entry.startedAt < PREFETCH_TTL_MS) return;
const response = nativeFetch(href);
prefetched.set(href, { response, startedAt: Date.now() });
response.then(
(r) => { if (!r.ok) prefetched.delete(href); },
() => prefetched.delete(href),
);
};
window.fetch = function fetch(input, init) {
if (typeof input === "string" && init === undefined) {
const entry = prefetched.get(input);
if (entry && Date.now() - entry.startedAt < PREFETCH_TTL_MS) {
prefetched.delete(input);
return entry.response.then((r) => r.clone());
}
}
return nativeFetch(input, init);
};
document.addEventListener("pointerover", (event) => {
clearTimeout(hoverTimer);
const href = routedHref(event.target);
if (href) hoverTimer = setTimeout(() => prefetch(href), HOVER_DELAY_MS);
}, { capture: true, passive: true });
document.addEventListener("pointerout", () => clearTimeout(hoverTimer), { capture: true, passive: true });
document.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey) return;
const href = routedHref(event.target);
if (href) prefetch(href);
}, { capture: true, passive: true });
// The router lets a click through as a full page load while it thinks Cmd or
// Ctrl is held, and it only learns of the release from a keyup on this page.
// Cmd-Tab, Cmd-L, Cmd-T and other browser shortcuts release the key
// elsewhere, so every later click became a full page load. Tell it the key is
// up whenever focus leaves the page or a click comes without the modifier.
const releaseModifier = () => window.dispatchEvent(new KeyboardEvent("keyup", { key: "Meta", keyCode: 91 }));
window.addEventListener("blur", releaseModifier);
window.addEventListener("click", (event) => {
if (!event.metaKey && !event.ctrlKey) releaseModifier();
}, true);
window.addEventListener("page-transition-loading", () => {
document.documentElement.classList.add("vgm-fast-loading");
});
window.addEventListener("page-transition-entering", () => {
document.documentElement.classList.remove("vgm-fast-loading");
});
})();
{
"manifest_version": 3,
"name": "Van Gogh Museum Fast Transitions",
"version": "1.5.0",
"description": "Makes page transitions on vangoghmuseum.nl fast: shorter fades, pages fetched on hover, and paintings that grow out of and back into their grid tiles.",
"content_scripts": [
{
"matches": ["https://www.vangoghmuseum.nl/*"],
"js": ["fast.js", "shared.js"],
"css": ["fast.css"],
"run_at": "document_start",
"world": "MAIN"
}
]
}
// The painting moves between its grid tile and the artwork page's header image:
// it grows out of the tile that was clicked, and on the way back it shrinks
// into that artwork's tile in the grid.
//
// The site swaps pages itself (see fast.js), so this flies a copy of the image
// above the page: lifted at the clicked tile while the next page loads, then
// moved to where the new page shows the painting once that page is in place.
(() => {
const DURATION_MS = 380;
const EASING = "cubic-bezier(.2, 0, 0, 1)";
const LANDING_WAIT_MS = 1500;
const NAVIGATION_WAIT_MS = 10_000;
const PRELOAD_HOVER_MS = 150; // longer than the page prefetch: these images are large
const ART_PATH = /^\/[a-z]{2}\/(?:[\w-]+\/)?collecti(?:on|e)\/[a-z]\d/i; // also /en/prints/collection/..., /nl/collectie/...
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
let flight = null; // { overlay, aspect, to: "header" | "tile", path, startedAt }
let currentPath = location.pathname;
const aspectOf = (img) => (img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 0);
// The rectangle the painting covers, also for object-fit: contain images.
const paintingRect = (img, aspect) => {
const box = img.getBoundingClientRect();
if (!aspect || getComputedStyle(img).objectFit !== "contain") return box;
let { width, height } = box;
if (width / height > aspect) width = height * aspect;
else height = width / aspect;
return new DOMRect(box.x + (box.width - width) / 2, box.y + (box.height - height) / 2, width, height);
};
const isOnScreen = (rect) =>
rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth;
const place = (el, rect) => Object.assign(el.style, {
left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.width}px`, height: `${rect.height}px`,
});
const launch = (img, to, path) => {
const aspect = aspectOf(img);
const rect = paintingRect(img, aspect);
if (!aspect || !isOnScreen(rect)) return;
const overlay = document.createElement("div");
overlay.className = "vgm-flight";
const copy = new Image();
copy.src = img.currentSrc;
overlay.append(copy);
place(overlay, rect);
document.body.append(overlay);
const thisFlight = { overlay, aspect, to, path, startedAt: performance.now(), landing: false };
flight = thisFlight;
document.documentElement.classList.add("vgm-flying");
// The site falls back to a full page load when its fetch fails, but don't
// leave the copy hanging over the page if the swap never happens.
setTimeout(() => { if (flight === thisFlight && !thisFlight.landing) fadeAway(); }, NAVIGATION_WAIT_MS);
};
const finish = () => {
if (!flight) return;
const { overlay } = flight;
flight = null;
document.documentElement.classList.remove("vgm-flying");
requestAnimationFrame(() => overlay.remove());
};
const fadeAway = () => {
if (!flight) return;
flight.overlay.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 150, fill: "forwards" }).finished.then(finish);
};
// FLIP: put the overlay where it lands, then animate it in from where it is.
const fly = (target, targetRect) => {
const { overlay } = flight;
const from = overlay.getBoundingClientRect();
place(overlay, targetRect);
target.dataset.vgmLanded = "";
const hiRes = target.currentSrc && target.currentSrc !== overlay.firstChild.src && target.complete ? target.currentSrc : null;
if (hiRes) {
const sharp = new Image();
sharp.src = hiRes;
sharp.decode().then(() => overlay.append(sharp), () => {});
}
overlay.animate([
{
transform: `translate(${from.x - targetRect.x}px, ${from.y - targetRect.y}px) scale(${from.width / targetRect.width}, ${from.height / targetRect.height})`,
},
{ transform: "none" },
], { duration: DURATION_MS, easing: EASING }).finished.then(finish, finish);
};
// Where the pointer is, so a flight back to the grid knows whether the tile
// it lands on is about to show the site's hover zoom.
let pointer = null;
document.addEventListener("pointermove", (event) => { pointer = { x: event.clientX, y: event.clientY }; }, { capture: true, passive: true });
document.documentElement.addEventListener("pointerleave", () => { pointer = null; });
// A grid tile under the pointer zooms in (the site's hover effect), but only
// once the browser notices the hover, a moment after the grid appears, and
// then over 300 ms. Landing on such a tile, apply that zoom at once so the
// painting lands at the size the tile will have, and hand the tile back to
// the site's own hover effect once the pointer leaves it.
const holdHoverZoom = (link) => {
const item = link.querySelector(".collection-art-object-item");
const zoomed = link.querySelector(".collection-art-object-item-single-image");
if (!pointer || !item || !zoomed || "vgmHover" in zoomed.dataset) return;
const isOver = () => {
const box = item.getBoundingClientRect();
return pointer && pointer.x >= box.left && pointer.x <= box.right && pointer.y >= box.top && pointer.y <= box.bottom;
};
if (!isOver()) return;
zoomed.dataset.vgmHover = "";
const release = () => {
if (isOver() && zoomed.isConnected) return;
delete zoomed.dataset.vgmHover;
document.removeEventListener("pointermove", release, true);
removeEventListener("scroll", release, true);
};
document.addEventListener("pointermove", release, true);
addEventListener("scroll", release, true);
};
// Waits (a frame at a time) for the new page to lay out the painting.
const land = () => {
if (!flight) return;
let target = null;
if (flight.to === "header") {
const container = document.querySelector(".art-object-page .image-and-buttons-container.image-loaded");
target = container?.querySelector(".art-object-header-image img") ?? null;
} else {
const link = [...document.querySelectorAll("a.collection-art-object-wrapper[href]")]
.find((a) => new URL(a.href).pathname === flight.path);
target = link?.querySelector("img") ?? null;
if (link) holdHoverZoom(link);
}
const rect = target && paintingRect(target, flight.aspect);
if (rect && isOnScreen(rect)) return fly(target, rect);
if (flight.to === "tile" && target && rect?.width > 0) return fadeAway(); // its tile is scrolled away
if (performance.now() - flight.startedAt > LANDING_WAIT_MS) return fadeAway();
requestAnimationFrame(land);
};
// The artwork page lays the painting out only once its large image has
// loaded, so the flight has to wait for that download. Start it while the
// pointer is on the tile, asking for the same candidates as the page does
// (its header uses the tile's WebP sizes from 600 px up at 90vw), so the
// browser picks the same file and the page finds it in the cache.
const preloaded = new Set();
const preloadHeaderImage = (link) => {
const source = link.querySelector('picture source[type="image/webp"]');
const srcset = source?.getAttribute("srcset") || source?.getAttribute("data-srcset");
if (!srcset || preloaded.has(link.pathname)) return;
// Split between candidates only: the IIIF URLs themselves contain commas.
const candidates = srcset.split(/,(?=\s*https?:)/).map((c) => c.trim()).filter((c) => parseInt(c.split(/\s+/)[1], 10) >= 600);
if (!candidates.length) return;
preloaded.add(link.pathname);
const img = new Image();
img.sizes = "90vw";
img.srcset = candidates.join(", ");
};
const tileLink = (target) => {
const link = target instanceof Element ? target.closest("a.collection-art-object-wrapper[href]") : null;
return link && link.host === location.host && ART_PATH.test(link.pathname) ? link : null;
};
let preloadTimer = 0;
document.addEventListener("pointerover", (event) => {
clearTimeout(preloadTimer);
const link = tileLink(event.target);
if (link) preloadTimer = setTimeout(() => preloadHeaderImage(link), PRELOAD_HOVER_MS);
}, { capture: true, passive: true });
document.addEventListener("pointerdown", (event) => {
const link = tileLink(event.target);
if (link) preloadHeaderImage(link);
}, { capture: true, passive: true });
// Grid tile (on the collection page, or related works on an artwork page) to the artwork.
document.addEventListener("click", (event) => {
if (reducedMotion.matches || flight) return;
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const link = event.target instanceof Element ? event.target.closest("a.collection-art-object-wrapper[href]") : null;
const img = link?.querySelector("img");
if (!img || link.host !== location.host || !ART_PATH.test(link.pathname)) return;
launch(img, "header", link.pathname);
link.querySelector(".collection-art-object-item-single-image")?.style.setProperty("visibility", "hidden");
}, true);
// Leaving an artwork page by any route (a link or the back button).
window.addEventListener("page-transition-loading", () => {
if (reducedMotion.matches || flight) return;
const img = document.querySelector(".art-object-page .art-object-header-image img");
if (img && ART_PATH.test(currentPath)) launch(img, "tile", currentPath);
});
window.addEventListener("page-transition-entering", () => {
if (!flight) return;
flight.landing = true;
flight.startedAt = performance.now();
requestAnimationFrame(land);
});
window.addEventListener("page-transition-entered", () => {
currentPath = location.pathname;
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment