Shared-element Transition
A thumbnail grows into the full view. In a gallery, the picture you click grows to fill it, caption and all. Between pages, it grows to fill the screen and then settles into its partner on the next page. This page uses it: try the three small photos.
How to use it
- Add the snippet to every page of your site, and
data-shared-element-transitionto each page’s<body>. - Give the picture inside a link
data-shared="name", and the big version on the page it opens the samedata-shared="name".
Options
Add any of these attributes to the same element.
data-shared="name" | On the elements to connect: one inside the link, its partner on the next page. |
data-duration | Time for each half. Default 600 ms. |
data-autoplay | Gallery mode only: milliseconds between opening and closing pictures, until someone interacts. |
Only links holding a data-shared element animate; others are left alone, and if anything goes wrong the link still opens. If the next page has no partner on screen, the picture fades away instead. Put data-shared-element-transition on a gallery instead and each picture grows to fill it when clicked; a click, Esc or the close button shrinks it back.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Shared-element Transition: a thumbnail grows into the full view. Click a
// link holding an element marked data-shared="name" and that element grows
// to fill the screen; on the next page it settles into the element with the
// same data-shared name (say, the article's hero image). Links without one
// are left alone.
// Usage: <body data-shared-element-transition> (add the snippet to every page)
// Page one: <a href="/lake.html"><img data-shared="lake" src="lake.jpg" alt="…"></a>
// Page two: <img data-shared="lake" src="lake.jpg" alt="…">
// Optional: data-duration (ms for each half, default 600).
// Contained mode: put data-shared-element-transition on a gallery (any
// other element whose children are the pictures). Clicking one grows it to
// fill the gallery with its caption; click, Esc or the close button shrinks
// it back. data-autoplay (ms) opens and closes them on a timer until
// someone interacts.
var DEFAULTS = { duration: 600, autoplay: 2600 };
var KEY = 'animacious-shared';
var CLOSE = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
var css =
'.set-fly{position:fixed;z-index:2147483001;overflow:hidden;pointer-events:none;margin:0;box-shadow:0 24px 60px rgba(15,15,30,.28)}' +
'.set-fly>*{display:block;width:100%!important;height:100%!important;max-width:none!important;margin:0!important;object-fit:cover;transform:none!important;visibility:visible!important}' +
'.set-back{position:fixed;inset:0;z-index:2147483000;pointer-events:none;opacity:0}' +
'[data-shared-element-transition]:not(body){position:relative}' +
'[data-shared-element-transition]:not(body):not([aria-hidden="true"])>*{cursor:zoom-in}' +
'.set-view{position:absolute!important;z-index:5;overflow:hidden;margin:0;cursor:zoom-out!important}' +
'.set-view>:first-child{width:100%;height:100%;margin:0;object-fit:cover}' +
'.set-view>img:first-child,.set-view>svg:first-child{display:block}' +
'.set-cap{position:absolute;left:0;right:0;bottom:0;padding:48px 22px 18px;background:linear-gradient(transparent,rgba(0,0,0,.6));color:#fff;font-weight:700;font-size:clamp(15px,2.4vw,24px);text-align:left;opacity:0}' +
'.set-close{position:absolute;top:12px;right:12px;display:grid;place-items:center;width:34px;height:34px;padding:0;border:0;border-radius:50%;background:rgba(0,0,0,.45);color:#fff;cursor:pointer;opacity:0}' +
'.set-close:focus-visible{outline:2px solid #fff;outline-offset:2px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
// A copy of an element that still looks right anywhere: ids renamed (with
// their url(#…) and href="#…" references) so SVG gradients keep working.
function copyHTML(el) {
var html = el.outerHTML, tag = 's' + Math.random().toString(36).slice(2, 7) + '-';
[el].concat(Array.from(el.querySelectorAll('[id]'))).forEach(function (n) {
if (!n.id) return;
var id = n.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
html = html.replace(new RegExp('(id="|url\\(#|href="#)' + id + '(?=[")])', 'g'), '$1' + tag + n.id);
});
return html;
}
function look(el) {
var cs = getComputedStyle(el);
return { radius: cs.borderRadius, bg: cs.backgroundColor };
}
function flyer(html, l, rect, parent) {
var f = document.createElement('div');
f.className = 'set-fly';
f.setAttribute('aria-hidden', 'true');
f.innerHTML = html;
f.style.background = l.bg;
place(f, rect, l.radius);
(parent || document.body).appendChild(f);
return f;
}
function place(f, r, radius) {
anime.utils.set(f, { left: r.left, top: r.top, width: r.width, height: r.height, borderRadius: radius });
}
function bgOf() {
var bg = getComputedStyle(document.body).backgroundColor;
return bg && !/rgba\(.*,\s*0\)$/.test(bg) && bg !== 'transparent' ? bg : '#fff';
}
// Same-site links that should get the transition: not new tabs, downloads,
// other sites or #anchors on this page.
function linkFor(e) {
var a = e.target.closest && e.target.closest('a[href]');
if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return null;
if ((a.target && a.target !== '_self') || a.hasAttribute('download')) return null;
var url = new URL(a.href, location.href);
if (url.origin !== location.origin) return null;
if (url.pathname === location.pathname && url.search === location.search && url.hash) return null;
return { a: a, url: url };
}
// ---------- Page mode ----------
function pageMode(body) {
var duration = +body.dataset.duration || DEFAULTS.duration;
var trip = 0, fly = null, back = null;
function clear() {
if (fly) fly.remove();
if (back) back.remove();
fly = back = null;
}
// Arriving: the element from the last page fills the screen, then shrinks
// into its partner here (or simply fades if this page doesn't have one).
var saved = null;
try { saved = JSON.parse(sessionStorage.getItem(KEY)); sessionStorage.removeItem(KEY); } catch (e) { /* storage blocked */ }
if (saved && Date.now() - saved.t < 8000) {
var full = { left: 0, top: 0, width: innerWidth, height: innerHeight };
back = document.createElement('div');
back.className = 'set-back';
back.style.background = bgOf();
back.style.opacity = 1;
body.appendChild(back);
fly = flyer(saved.html, saved, full);
fly.style.boxShadow = 'none';
// Let the page lay out (and jump to any #anchor) before measuring.
requestAnimationFrame(function () {
requestAnimationFrame(function () {
if (!fly) return;
var target = null;
document.querySelectorAll('[data-shared]').forEach(function (n) {
if (!target && (n.getAttribute('data-shared') || 'shared') === saved.key) target = n;
});
var r = target && target.getBoundingClientRect();
var onScreen = r && r.width && r.bottom > 0 && r.top < innerHeight;
anime.animate(back, { opacity: 0, duration: duration, ease: 'out(2)' });
if (onScreen) {
target.style.visibility = 'hidden';
anime.animate(fly, {
left: r.left, top: r.top, width: r.width, height: r.height,
borderRadius: getComputedStyle(target).borderRadius,
duration: duration, ease: 'inOut(4)',
onComplete: function () { target.style.visibility = ''; clear(); }
});
} else {
anime.animate(fly, { opacity: 0, scale: 1.04, duration: duration, ease: 'out(2)', onComplete: clear });
}
});
});
}
document.addEventListener('click', function (e) {
var hit = linkFor(e);
if (!hit) return;
var src = hit.a.hasAttribute('data-shared') ? hit.a : hit.a.querySelector('[data-shared]');
if (!src) return;
e.preventDefault();
var t = ++trip, gone = false;
var go = function () { if (gone || t !== trip) return; gone = true; location.href = hit.url.href; };
// Never hold up the visit: go anyway if anything stalls or fails.
setTimeout(go, duration + 500);
try {
var l = look(src), html = copyHTML(src);
try {
sessionStorage.setItem(KEY, JSON.stringify({ key: src.getAttribute('data-shared') || 'shared', html: html, radius: '0px', bg: l.bg, t: Date.now() }));
} catch (err) { /* too big or blocked: it just won't settle on the next page */ }
clear();
back = document.createElement('div');
back.className = 'set-back';
back.style.background = bgOf();
body.appendChild(back);
fly = flyer(html, l, src.getBoundingClientRect());
src.style.visibility = 'hidden';
anime.animate(back, { opacity: 1, duration: duration, ease: 'in(2)' });
anime.animate(fly, {
left: 0, top: 0, width: innerWidth, height: innerHeight, borderRadius: 0,
duration: duration, ease: 'inOut(4)', onComplete: go
});
} catch (err) { go(); }
});
// Coming back with the Back button can restore the page mid-flight: put
// the element back, and cancel the visit that page was on its way to.
window.addEventListener('pageshow', function (e) {
if (!e.persisted) return;
trip++;
try { sessionStorage.removeItem(KEY); } catch (err) { /* ignore */ }
document.querySelectorAll('[data-shared]').forEach(function (n) { n.style.visibility = ''; });
if (back) anime.animate(back, { opacity: 0, duration: 250 });
if (fly) anime.animate(fly, { opacity: 0, duration: 250, onComplete: clear });
});
}
// ---------- Contained mode: a gallery whose pictures grow to fill it ----------
function containedMode(el, reduced) {
var items = Array.from(el.children);
var duration = +el.dataset.duration || DEFAULTS.duration;
var inThumb = !!el.closest('[aria-hidden="true"]');
var view = null, opened = null, busy = false, stopped = false, hovered = false, visible = false;
if (!inThumb) items.forEach(function (it) {
var cap = it.querySelector('figcaption');
if (!it.hasAttribute('tabindex')) it.tabIndex = 0;
it.setAttribute('role', 'button');
if (!it.getAttribute('aria-label')) it.setAttribute('aria-label', 'Open ' + (cap ? cap.textContent.trim() : 'picture'));
});
function rectIn(n) {
var a = n.getBoundingClientRect(), b = el.getBoundingClientRect();
return { left: a.left - b.left - el.clientLeft, top: a.top - b.top - el.clientTop, width: a.width, height: a.height };
}
function open(it, user) {
if (busy || opened) return;
busy = true;
opened = it;
var media = it.querySelector('img,svg,picture,video') || it;
var cap = it.querySelector('figcaption');
view = document.createElement('figure');
view.className = 'set-view';
view.innerHTML = copyHTML(media);
if (!inThumb) view.setAttribute('aria-label', cap ? cap.textContent.trim() : 'Picture');
var c = null, x = null;
if (cap) {
c = document.createElement('figcaption');
c.className = 'set-cap';
c.textContent = cap.textContent;
view.appendChild(c);
}
if (!inThumb) {
x = document.createElement('button');
x.type = 'button';
x.className = 'set-close';
x.setAttribute('aria-label', 'Close');
x.innerHTML = CLOSE;
view.appendChild(x);
}
el.appendChild(view);
var from = rectIn(media);
anime.utils.set(view, { left: from.left, top: from.top, width: from.width, height: from.height, borderRadius: getComputedStyle(media).borderRadius });
media.style.visibility = 'hidden';
var extras = [c, x].filter(Boolean);
var tl = anime.createTimeline({ onComplete: function () { busy = false; if (x && user) x.focus({ preventScroll: true }); } });
tl.add(view, {
left: 0, top: 0, width: el.clientWidth, height: el.clientHeight, borderRadius: getComputedStyle(el).borderRadius,
boxShadow: ['0 0 0 rgba(15,15,30,0)', '0 24px 60px rgba(15,15,30,.3)'],
duration: reduced ? 0 : duration, ease: 'inOut(4)'
});
if (extras.length) tl.add(extras, { opacity: [0, 1], y: [10, 0], duration: reduced ? 0 : 300, ease: 'out(3)', delay: anime.stagger(60) }, reduced ? 0 : duration * 0.7);
}
function close(refocus) {
if (busy || !opened) return;
busy = true;
var it = opened, v = view;
var media = it.querySelector('img,svg,picture,video') || it;
var to = rectIn(media);
var extras = Array.from(v.querySelectorAll('.set-cap,.set-close'));
if (extras.length) anime.animate(extras, { opacity: 0, duration: reduced ? 0 : 150 });
anime.animate(v, {
left: to.left, top: to.top, width: to.width, height: to.height,
borderRadius: getComputedStyle(media).borderRadius,
boxShadow: '0 0 0 rgba(15,15,30,0)',
duration: reduced ? 0 : duration * 0.85, ease: 'inOut(4)',
onComplete: function () {
media.style.visibility = '';
v.remove();
view = opened = null;
busy = false;
if (refocus && !inThumb) it.focus({ preventScroll: true });
}
});
}
function stop() { stopped = true; }
// A decorative copy (e.g. inside a link card) only plays itself.
if (!inThumb) items.forEach(function (it) {
it.addEventListener('click', function () { stop(); open(it, true); });
it.addEventListener('keydown', function (e) {
if (e.target !== it || (e.key !== 'Enter' && e.key !== ' ')) return;
e.preventDefault();
stop();
open(it, true);
});
});
if (!inThumb) el.addEventListener('click', function (e) {
if (view && view.contains(e.target)) { stop(); close(true); }
});
el.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && opened) { stop(); close(true); }
});
el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') hovered = true; });
el.addEventListener('pointerleave', function () { hovered = false; });
// Autoplay: open a picture, hold it, close it, pick another.
var auto = el.dataset.autoplay;
if (auto === undefined || auto === 'false' || reduced) return;
var every = +auto || DEFAULTS.autoplay, n = 0;
new IntersectionObserver(function (entries) { visible = entries[0].isIntersecting; }).observe(el);
setInterval(function () {
if (stopped || hovered || !visible || el.closest('[hidden]')) return;
if (opened) close(false);
else open(items[(n = (n + 1 + Math.floor(Math.random() * (items.length - 1))) % items.length)]);
}, every);
}
function init() {
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
document.querySelectorAll('[data-shared-element-transition]').forEach(function (el) {
if (el._set) return;
el._set = true;
if (el === document.body) { if (!reduced) pageMode(el); }
else if (el.children.length) containedMode(el, reduced);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><!-- The list page -->
<body data-shared-element-transition>
<a href="/lake.html">
<img data-shared="lake" src="lake.jpg" alt="Mountain lake">
</a>
</body>
<!-- lake.html -->
<body data-shared-element-transition>
<img data-shared="lake" src="lake.jpg" alt="Mountain lake">
</body>