View Transitions Morph
Give the same name to a thumbnail on one page and the big picture on the next, and the browser’s View Transitions API glides and resizes it from one place to the other while the rest cross-fades. Here a card opens into its own page and closes again, its picture and title morphing each way.
How to use it
- Paste the snippet just before
</head>. - Add the snippet to every page of your site.
- Add
data-view-transitions-morphto each page’s<body>. - Give matching elements on the two pages the same
data-morphname, e.g. a card’s image and the article’s hero image.
Options
Add any of these attributes to the same element.
data-morph="name" | On any element: the name that pairs it with its twin on the next page or view. Each name once per page. |
data-duration | Time for each morph. Default 600 ms. |
data-ease | Any CSS easing. Default cubic-bezier(.7,0,.2,1). |
data-interval | Contained mode only: time each view stays. Default 2200 ms. |
Between pages this needs a browser with cross-document view transitions (Chrome and Edge 126+, Safari 18.2+); elsewhere links simply open as usual, so it’s safe everywhere. Both pages need the snippet. Put data-view-transitions-morph on any other element whose children are views and it steps through them, like the demo above, using the same API within the page (with a plain fade where it isn’t supported). With reduced motion the views just swap.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// View Transitions Morph: elements morph between pages with the browser's
// View Transitions API. Mark the same thing on two views (a thumbnail and
// the big picture it opens, a card title and the page heading) with the same
// data-morph name, and the browser glides and resizes it from one place to
// the other while everything else cross-fades.
// Usage: <body data-view-transitions-morph> (add the snippet to every page)
// <img data-morph="hero" …> (the same name on the matching element
// of the next page)
// Optional: data-duration (ms, default 600), data-ease (any CSS easing,
// default cubic-bezier(.7,0,.2,1)).
// Contained mode: put data-view-transitions-morph on any other element whose
// children are views. Every data-interval ms (default 2200) it moves to the
// next view, morphing every [data-morph] element that both views share.
// Browsers without the API (or with reduced motion) simply swap views.
var DEFAULTS = { duration: 600, ease: 'cubic-bezier(.7,0,.2,1)', interval: 2200 };
var ATTR = 'data-view-transitions-morph';
var css =
'.vtm-contained{display:grid}' +
'.vtm-contained>*{grid-area:1/1;min-width:0}' +
'.vtm-contained>.vtm-off{display:none}' +
// While a demo morphs, the rest of the page stays live and clickable.
'html.vtm-live{view-transition-name:none}' +
'html.vtm-live::view-transition{pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function ident(s) { return String(s).trim().replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^(-?\d)/, 'm$1'); }
function timing(node) {
return { duration: +node.dataset.duration || DEFAULTS.duration, ease: node.dataset.ease || DEFAULTS.ease };
}
// ---------- Page mode: cross-document view transitions ----------
// The opt-in must be in place before the new page first paints, so it goes
// in as soon as <body> exists, before the rest of the page is parsed.
function pageMode(body) {
if (body._vtm) return;
body._vtm = true;
var t = timing(body);
var s = document.createElement('style');
s.textContent = '@view-transition{navigation:auto}' +
'::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation-duration:' + t.duration + 'ms;animation-timing-function:' + t.ease + '}';
document.head.appendChild(s);
function name() {
document.querySelectorAll('[data-morph]').forEach(function (n) {
if (!n.style.viewTransitionName) n.style.viewTransitionName = ident(n.getAttribute('data-morph'));
});
}
name();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', name);
// Back to a page kept in memory: names may have been cleared by the morph.
window.addEventListener('pageshow', name);
}
if (document.body) { if (document.body.hasAttribute(ATTR)) pageMode(document.body); }
else {
var mo = new MutationObserver(function () {
if (!document.body) return;
mo.disconnect();
if (document.body.hasAttribute(ATTR)) pageMode(document.body);
});
mo.observe(document.documentElement, { childList: true });
}
// ---------- Contained mode: views in one element ----------
var count = 0;
function containedMode(el, still) {
el.classList.add('vtm-contained');
var views = Array.from(el.children);
var t = timing(el), id = 'vtm' + (++count), i = 0;
views.forEach(function (v, n) { v.classList.toggle('vtm-off', n > 0); });
if (still) return;
// Names go only on the view being shown: no two visible elements may
// share one.
function names(view, on) {
view.querySelectorAll('[data-morph]').forEach(function (n) {
n.style.viewTransitionName = on ? id + '-' + ident(n.getAttribute('data-morph')) : '';
});
}
function swap(from, to) {
names(from, false);
from.classList.add('vtm-off');
to.classList.remove('vtm-off');
names(to, true);
}
function next() {
var n = (i + 1) % views.length, from = views[i], to = views[n];
i = n;
if (!document.startViewTransition || document.hidden) {
// No API: a plain cross-fade.
swap(from, to);
anime.animate(to, { opacity: [0, 1], duration: t.duration, ease: 'out(2)' });
return;
}
var html = document.documentElement;
html.classList.add('vtm-live');
el.style.viewTransitionName = id;
names(from, true);
var vt;
try { vt = document.startViewTransition(function () { swap(from, to); }); }
catch (e) { swap(from, to); html.classList.remove('vtm-live'); return; }
vt.ready.then(function () {
// This demo's own timing, applied to its own pseudo-elements.
document.getAnimations().forEach(function (a) {
var p = a.effect && a.effect.pseudoElement;
if (p && p.indexOf('::view-transition') === 0 && p.indexOf('(' + id) > 0) {
a.effect.updateTiming({ duration: t.duration, easing: t.ease });
}
});
}).catch(function () {});
vt.finished.finally(function () {
html.classList.remove('vtm-live');
el.style.viewTransitionName = '';
});
}
var timer = anime.createTimer({
duration: +el.dataset.interval || DEFAULTS.interval,
loop: true,
autoplay: false,
onLoop: function () { if (!el.closest('[hidden]')) next(); }
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) timer.play(); else timer.pause();
}).observe(el);
}
function init() {
var still = reduced();
document.querySelectorAll('[' + ATTR + ']').forEach(function (el) {
if (el === document.body) { pageMode(el); return; }
if (el._vtm) return;
el._vtm = true;
if (el.children.length > 1) containedMode(el, still);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><!-- list page -->
<body data-view-transitions-morph>
<a href="/lake">
<img src="lake.jpg" data-morph="lake-photo" alt="">
<h3 data-morph="lake-title">Lake</h3>
</a>
</body>
<!-- /lake -->
<body data-view-transitions-morph>
<img src="lake.jpg" data-morph="lake-photo" alt="">
<h1 data-morph="lake-title">Lake</h1>
</body>