共享元素
同一个视觉元素连接起两个不同的画面。
观察同一张潮线图怎样从目录卡片移到详情页。
当前画面 · 作品目录
使用场景
从作品卡片进入详情。例如封面图从缩略位置移动到详情主图位置,读者能持续追踪。
行为定义
保留一个视觉锚点,让它沿起止位置和尺寸插值,其余内容在同一舞台中切换。
实现要点
原生用两次测量和 Web Animations API,GSAP 用 Flip.getState() 与 Flip.from(),启用 scale: true。
原生 HTML / CSS / JS
完整示例,可另存为 HTML 直接打开。
打开示例查看源码
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>共享元素转场 · 原生</title>
<style>
html { background: #fbfaf7; }
body { margin: 2rem auto; padding: 0 1rem; max-width: 38rem; color: #222c36; font: 1rem/1.55 system-ui, sans-serif; }
[data-transition-viewport] { position: relative; isolation: isolate; overflow: hidden; aspect-ratio: 2 / 1; border-radius: 12px; background: #edf3f6; }
[data-transition-view] { position: absolute; inset: 0; padding: 5% 7%; background: #edf3f6; }
[data-transition-view="detail"] { background: #f0ebf5; }
[data-shared-art] { position: absolute; top: 30%; left: 7%; width: 40%; transform-origin: top left; }
[data-transition-view="detail"] [data-shared-art] { top: 25%; left: 39%; width: 54%; }
[data-shared-art] svg { display: block; width: 100%; border-radius: 4px; }
[data-transition-mask] { position: absolute; z-index: 3; inset: 0; background: #6d5581; pointer-events: none; transform: translateX(-100%); }
button { min-height: 2.75rem; margin: .15rem; padding: .35rem .65rem; border: 1px solid #9786a8; border-radius: 6px; background: #fff; color: #222c36; font: inherit; }
button[aria-pressed='true'] { border-color: #6d5581; background: #f0ebf5; }
button:focus-visible { outline: 2px solid #6d5581; outline-offset: 2px; }
[data-transition-status] { color: #574268; }
@media (max-width: 30rem) {
[data-transition-viewport] { aspect-ratio: 1.62 / 1; }
}
[data-transition-view] > .transition-guide-target {
position: absolute;
z-index: 2;
inset: 62% auto auto 55%;
min-block-size: 44px;
margin: 0;
padding: .4rem .75rem;
border: 1px solid var(--transition-accent, #6d5581);
border-radius: 7px;
background: var(--surface, #fff);
color: var(--text, #222c36);
font: inherit;
font-size: .8125rem;
cursor: pointer;
}
.transition-guide-target:is(:hover, :active) { background: var(--transition-tint, #f0ebf5); }
.transition-guide-target:focus-visible { outline: 2px solid var(--transition-accent, #6d5581); outline-offset: 3px; }
</style>
<style>.demo-guide {
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem .9rem;
min-inline-size: 0;
margin-block: 0 .75rem;
padding-block-end: .5rem;
border-block-end: 1px solid var(--line-hi, #b8c4cd);
color: var(--text-2, #434f5d);
font-size: .8125rem;
line-height: 1.5;
}
.trigger-stage > .demo-guide {
margin: .35rem clamp(.85rem, 2.2vw, 1.4rem) 0;
}
.demo-guide [data-guide-caption] { margin: 0; }
.demo-guide > button[data-guide-replay] {
flex: none;
min-block-size: 44px;
margin: 0;
padding: .35rem .65rem;
border: 1px solid var(--line-strong, #82939f);
border-radius: 7px;
background: var(--surface, #fff);
color: var(--text, #222c36);
font: inherit;
font-weight: 550;
cursor: pointer;
}
.demo-guide > button[data-guide-replay]:hover { border-color: var(--accent-text, #963324); }
.demo-guide > button[data-guide-replay]:focus-visible { outline: 2px solid var(--accent-text, #963324); outline-offset: 2px; }
.demo-guide-cursor {
position: fixed;
z-index: 20;
inset: 0 auto auto 0;
inline-size: 20px;
block-size: 24px;
pointer-events: none;
}
.demo-guide-cursor svg { display: block; inline-size: 100%; block-size: 100%; }
.demo-guide-cursor::before {
position: absolute;
inset: -7px auto auto -7px;
inline-size: 18px;
block-size: 18px;
border: 2px solid var(--accent-text, #963324);
border-radius: 50%;
content: '';
opacity: 0;
}
.demo-guide-cursor[data-press]::before { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .demo-guide-cursor { display: none; } }
</style>
</head>
<body>
<main data-transition-variant="shared-element">
<h1>共享元素转场</h1>
<div data-transition-viewport data-active-view="index">
<div data-transition-view="index" data-view-label="作品目录">
<strong>作品目录</strong>
<div data-shared-art data-flip-id="coastal-art">
<svg viewBox="0 0 220 112" aria-hidden="true" focusable="false">
<rect width="220" height="112" fill="#e8f3fa" />
<path d="M0 94l47-52 26 26 30-35 43 50 26-34 48 36v27H0Z" fill="#628ca8" />
<path d="M0 103l48-52 26 27 31-35 40 49 27-33 48 37" stroke="#39688b" fill="none" />
<circle cx="176" cy="31" r="9" fill="#ef784d" />
</svg>
</div>
<button class="transition-guide-target" type="button" data-transition-action="detail" data-transition-guide-target hidden>打开作品</button>
</div>
<div data-transition-view="detail" data-view-label="作品详情" hidden>
<strong>作品详情</strong>
<div data-shared-art data-flip-id="coastal-art">
<svg viewBox="0 0 220 112" aria-hidden="true" focusable="false">
<rect width="220" height="112" fill="#e8f3fa" />
<path d="M0 94l47-52 26 26 30-35 43 50 26-34 48 36v27H0Z" fill="#628ca8" />
<path d="M0 103l48-52 26 27 31-35 40 49 27-33 48 37" stroke="#39688b" fill="none" />
<circle cx="176" cy="31" r="9" fill="#ef784d" />
</svg>
</div>
</div>
<div data-transition-mask hidden></div>
</div>
<p data-transition-status role="status">当前画面 · 作品目录</p>
<div data-transition-controls hidden>
<button type="button" data-transition-action="index" aria-pressed="true">作品目录</button>
<button type="button" data-transition-action="detail" aria-pressed="false">作品详情</button>
</div>
<noscript><p>起始画面是目录的小图,终止画面是详情的大图。</p></noscript>
</main>
<script>
/** @typedef {{ finished: Promise<void>, stop: () => void }} TransitionMotion */
/** @typedef {{ next: HTMLElement, guided: boolean, motions: TransitionMotion[], styles: Map<HTMLElement, string | null> }} TransitionRun */
/** @param {HTMLElement} element @param {Keyframe[]} frames @param {number} duration @returns {TransitionMotion} */
function animateNative(element, frames, duration) {
const motion = element.animate(frames, {
duration,
easing: 'cubic-bezier(.22, 1, .36, 1)',
fill: 'both',
});
return { finished: motion.finished.then(() => {}, () => {}), stop: () => motion.cancel() };
}
/** @param {HTMLElement} firstArt @param {HTMLElement} lastArt @param {() => void} present */
function flipNative(firstArt, lastArt, present) {
const first = firstArt.getBoundingClientRect();
present();
const last = lastArt.getBoundingClientRect();
if (!first.width || !first.height || !last.width || !last.height) return;
// Measure each endpoint once. Transform the new artwork back to its old pose.
return animateNative(lastArt, [{
transform: `translate(${first.left - last.left}px, ${first.top - last.top}px) scale(${first.width / last.width}, ${first.height / last.height})`,
}, { transform: 'none' }], 560);
}
/** @param {HTMLElement} root @param {typeof animateNative} [animate] @param {typeof flipNative} [flip] */
function bindTransitionStage(root, animate = animateNative, flip = flipNative) {
const viewport = root.querySelector('[data-transition-viewport]');
const controls = root.querySelector('[data-transition-controls]');
const status = root.querySelector('[data-transition-status]');
const mask = root.querySelector('[data-transition-mask]');
const views = Array.from(root.querySelectorAll('[data-transition-view]')).filter((view) => view instanceof HTMLElement);
if (!(viewport instanceof HTMLElement) || !(controls instanceof HTMLElement) || !(mask instanceof HTMLElement) || !status || views.length !== 2) return;
const buttons = Array.from(controls.querySelectorAll('button[data-transition-action]'));
const guideTarget = viewport.querySelector('button[data-transition-guide-target]');
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const variant = root.dataset.transitionVariant;
let active = views[0];
/** @type {TransitionRun | undefined} */
let pending;
/** @param {HTMLElement} view */
const present = (view) => {
for (const candidate of views) {
candidate.hidden = candidate !== view;
if (guideTarget) candidate.setAttribute('aria-hidden', String(candidate.hidden));
}
};
/** @param {HTMLElement} view */
const commit = (view) => {
present(view);
active = view;
mask.hidden = true;
viewport.dataset.activeView = view.dataset.transitionView;
guideTarget?.removeAttribute('disabled');
for (const button of buttons) {
button.removeAttribute('aria-disabled');
button.setAttribute('aria-pressed', String(button.getAttribute('data-transition-action') === view.dataset.transitionView));
}
status.textContent = `当前画面 · ${view.dataset.viewLabel}`;
};
/** @param {TransitionRun} run @param {HTMLElement} [destination] */
const finish = (run, destination = run.next) => {
if (pending !== run) return;
pending = undefined;
for (const motion of run.motions) motion.stop();
for (const [element, style] of run.styles) {
if (style === null) element.removeAttribute('style');
else element.setAttribute('style', style);
}
commit(destination);
};
/** @param {TransitionRun} run @param {HTMLElement} element @param {Keyframe[]} frames @param {number} duration */
const move = (run, element, frames, duration) => {
const motion = animate(element, frames, duration);
run.motions.push(motion);
return motion.finished;
};
/** @param {TransitionRun} run @param {HTMLElement | null} firstArt @param {HTMLElement | null} lastArt */
const play = async (run, firstArt, lastArt) => {
const { next } = run;
const direction = next.dataset.transitionView === 'detail' ? 1 : -1;
if (variant === 'mask-transition') {
mask.hidden = false;
await move(run, mask, [{ transform: `translateX(${-100 * direction}%)` }, { transform: 'translateX(0%)' }], 225);
if (pending !== run) return;
present(next);
await move(run, mask, [{ transform: 'translateX(0%)' }, { transform: `translateX(${100 * direction}%)` }], 275);
} else if (variant === 'overlap-transition') {
next.hidden = false;
next.style.zIndex = '2';
active.style.zIndex = '1';
await Promise.all([
move(run, active, [{ transform: 'translateX(0%)', opacity: 1 }, { transform: `translateX(${-16 * direction}%)`, opacity: .28 }], 470),
move(run, next, [{ transform: `translateX(${27 * direction}%)`, opacity: 0 }, { transform: 'translateX(0%)', opacity: 1 }], 470),
]);
} else if (firstArt && lastArt) {
const motion = flip(firstArt, lastArt, () => present(next));
if (motion) {
run.motions.push(motion);
await motion.finished;
}
}
};
/** @param {HTMLElement} next @param {boolean} [guided] */
const switchTo = (next, guided = false) => {
if (pending) {
if (pending.guided) finish(pending, next);
return;
}
if (next === active) return;
if (reduced.matches || typeof viewport.animate !== 'function') {
commit(next);
return;
}
const firstArt = active.querySelector('[data-shared-art]');
const lastArt = next.querySelector('[data-shared-art]');
const styledElements = [active, next, mask, firstArt, lastArt].filter((element) => element instanceof HTMLElement);
const run = { next, guided, motions: [], styles: new Map(styledElements.map((element) => [element, element.getAttribute('style')])) };
pending = run;
guideTarget?.setAttribute('disabled', '');
if (!guided) {
for (const control of buttons) control.setAttribute('aria-disabled', 'true');
}
status.textContent = `切换至${next.dataset.viewLabel}`;
const before = firstArt instanceof HTMLElement ? firstArt : null;
const after = lastArt instanceof HTMLElement ? lastArt : null;
play(run, before, after).then(() => finish(run), () => finish(run));
};
root.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest('button[data-transition-action]');
if (!button) return;
const destination = button.getAttribute('data-transition-action');
const next = views.find((view) => view.dataset.transitionView === destination);
if (!next) return;
if (button === guideTarget && button === document.activeElement) {
const detailControl = controls.querySelector('button[data-transition-action="detail"]');
if (detailControl instanceof HTMLButtonElement) detailControl.focus({ preventScroll: true });
}
switchTo(next, !event.isTrusted && button === guideTarget && Boolean(root.closest('[data-guide-state="playing"]')));
});
if (guideTarget instanceof HTMLButtonElement) {
guideTarget.hidden = false;
root.addEventListener('formtempo:guide-reset', () => {
if (pending) finish(pending, views[0]);
else commit(views[0]);
});
root.addEventListener('formtempo:guide-cancel', () => {
if (pending?.guided) finish(pending);
});
}
window.addEventListener('resize', () => { if (pending) finish(pending); }, { passive: true });
reduced.addEventListener('change', () => {
if (!reduced.matches) return;
if (pending) finish(pending);
});
commit(active);
controls.hidden = false;
}
bindTransitionStage(document.querySelector('main'));
</script>
<script>
(() => {
/** @param {number} duration @param {AbortSignal} signal @param {(progress: number) => void} [render] */
function guideBeat(duration, signal, render) {
if (signal.aborted) return Promise.resolve();
if (duration === 0) {
render?.(1);
return Promise.resolve();
}
return new Promise((resolve) => {
let timer = 0;
let frame = 0;
const done = () => {
clearTimeout(timer);
cancelAnimationFrame(frame);
signal.removeEventListener('abort', done);
resolve(undefined);
};
signal.addEventListener('abort', done, { once: true });
if (!render) {
timer = window.setTimeout(done, duration);
return;
}
const start = performance.now();
const tick = (now) => {
const progress = Math.min(1, (now - start) / duration);
render(progress);
if (progress === 1) done();
else if (!signal.aborted) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
});
}
/** @param {HTMLElement} root @param {import('./demo-guide-cases').DemoGuide} guide */
function bindDemoGuide(root, guide) {
const view = root.querySelector(guide.view);
if (!view || root.dataset.guideState || guide.steps.some(({ target }) => !root.querySelector(target))) return;
const dock = guide.dock ? root.querySelector(guide.dock) : null;
const bar = document.createElement('div');
bar.className = 'demo-guide';
bar.innerHTML = '<p data-guide-caption></p><button type="button" data-guide-replay>重播导览</button>';
const caption = bar.querySelector('[data-guide-caption]');
const replay = bar.querySelector('button');
const heading = root === document.body ? root.querySelector('h1') : null;
if (dock) dock.prepend(bar);
else if (heading) heading.after(bar);
else root.prepend(bar);
const cursor = document.createElement('div');
cursor.className = 'demo-guide-cursor';
cursor.setAttribute('aria-hidden', 'true');
cursor.hidden = true;
cursor.innerHTML = '<svg viewBox="0 0 20 24" focusable="false"><path d="M2 2v17l4.8-4.5L10 21l3.2-1.6-3.2-6.2 6.6-.7L2 2Z" fill="#222c36" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>';
document.body.append(cursor);
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const threshold = .5;
const idleCaption = '导览在画面进入视口后播放一次。';
const reducedCaption = '可重播关键状态,或直接操作。';
const reset = guide.reset ? (root.matches(guide.reset) ? root : root.querySelector(guide.reset)) : null;
const releaseTarget = guide.release ? root.querySelector(guide.release) : null;
let autoPending = !reduced.matches;
let inView = false;
let playbackScroll = window.scrollY;
/** @type {AbortController | undefined} */
let run;
/** @type {[number, number] | undefined} */
let cursorPosition;
root.dataset.guideState = 'idle';
caption.textContent = reduced.matches ? reducedCaption : idleCaption;
function hideCursor() {
cursor.hidden = true;
delete cursor.dataset.press;
cursorPosition = undefined;
}
/** @param {PointerEvent} [pointer] */
function stop(pointer) {
autoPending = false;
if (!run) return;
run.abort();
run = undefined;
hideCursor();
if (releaseTarget) {
// A real pointer inside the target keeps its hover when the guide yields.
if (pointer) sendPointer(releaseTarget, 'pointerenter', [pointer.clientX, pointer.clientY]);
else sendPointer(releaseTarget, 'pointerleave', [0, 0]);
}
reset?.dispatchEvent(new Event('formtempo:guide-cancel'));
root.dataset.guideState = 'interrupted';
caption.textContent = '导览已停止,可以继续手动操作。';
}
/** @param {[number, number]} position */
function moveCursor(position) {
cursor.hidden = false;
cursor.style.transform = `translate3d(${position[0]}px, ${position[1]}px, 0)`;
cursorPosition = position;
}
/** @param {Element} target @param {readonly [number, number]} point */
function location(target, [x, y]) {
const rect = target.getBoundingClientRect();
return /** @type {[number, number]} */ ([rect.left + rect.width * x, rect.top + rect.height * y]);
}
/** @param {Element} target @param {string} type @param {readonly [number, number]} position */
function sendPointer(target, type, [clientX, clientY]) {
target.dispatchEvent(new PointerEvent(type, { pointerType: 'mouse', clientX, clientY }));
}
/** @param {HTMLInputElement} slider @param {number} value */
function setProgress(slider, value) {
const next = Math.round(value);
if (slider.valueAsNumber === next) return;
slider.value = String(next);
slider.dispatchEvent(new Event('input', { bubbles: true }));
}
/** @param {number} value */
function showReducedProgress(value) {
const options = Array.from(root.querySelectorAll('button[data-driven-step], button[data-scale-step], button[data-progress-step], button[data-progress]'));
const percent = (button) => button.hasAttribute('data-progress')
? Number(button.getAttribute('data-progress')) * 100
: Number(button.getAttribute('data-driven-step') ?? button.getAttribute('data-scale-step') ?? button.getAttribute('data-progress-step'));
options.sort((a, b) => Math.abs(percent(a) - value) - Math.abs(percent(b) - value));
if (options[0] instanceof HTMLButtonElement) options[0].click();
}
/** @param {import('./demo-guide-cases').DemoGuide['steps'][number]} step @param {AbortSignal} signal */
async function playStep(step, signal) {
const target = root.querySelector(step.target);
if (!target) return;
caption.textContent = step.text;
const { action, value = 0, point = [.5, .5] } = step;
if (action === 'progress' && target instanceof HTMLInputElement) {
if (reduced.matches) {
showReducedProgress(value);
} else {
const from = target.valueAsNumber;
await guideBeat(step.duration ?? 0, signal, (progress) => {
const current = from + (value - from) * progress;
setProgress(target, current);
moveCursor(location(target, [.04 + .92 * current / 100, .65]));
});
}
} else {
if (!reduced.matches) {
const to = location(target, point);
const [x, y] = point;
const entry = x < .25 ? [-.08, y] : x > .75 ? [1.08, y] : [x, y < .5 ? -.08 : 1.08];
const from = action === 'enter' ? location(target, /** @type {[number, number]} */ (entry))
: cursorPosition ?? [to[0] + 26, to[1] + 19];
const duration = action === 'leave' ? 200 : 340;
await guideBeat(duration, signal, (progress) => {
const t = 1 - (1 - progress) ** 3;
const end = location(target, point);
const position = /** @type {[number, number]} */ ([
from[0] + (end[0] - from[0]) * t,
from[1] + (end[1] - from[1]) * t,
]);
moveCursor(position);
if (action === 'move') sendPointer(target, 'pointermove', position);
});
}
if (signal.aborted) return;
if (action === 'click' && target instanceof HTMLElement) {
if (!reduced.matches) {
cursor.dataset.press = '';
await guideBeat(125, signal);
if (signal.aborted) return;
}
target.click();
delete cursor.dataset.press;
} else if (action === 'enter' || action === 'leave') {
sendPointer(target, `pointer${action}`, location(target, point));
}
}
await guideBeat(reduced.matches ? 600 : step.hold ?? 500, signal);
}
function bringViewIntoFrame() {
const rect = view.getBoundingClientRect();
const visibleHeight = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0);
if (visibleHeight < rect.height * threshold) view.scrollIntoView({ block: 'center', behavior: 'instant' });
}
async function start(replaying = false) {
stop();
const controller = new AbortController();
const { signal } = controller;
run = controller;
root.dataset.guideState = 'playing';
if (guide.ready) {
caption.textContent = '正在等待视频就绪…';
const deadline = performance.now() + 4000;
while (!signal.aborted && !root.querySelector(guide.ready) && performance.now() < deadline &&
!root.querySelector('[data-video-state="error"]')) {
await guideBeat(90, signal);
}
if (signal.aborted) return;
if (!root.querySelector(guide.ready)) {
run = undefined;
root.dataset.guideState = 'unavailable';
caption.textContent = '视频暂未就绪,可以查看关键帧或重播。';
return;
}
}
reset?.dispatchEvent(new Event('formtempo:guide-reset'));
if (replaying) bringViewIntoFrame();
playbackScroll = window.scrollY;
const steps = (reduced.matches && guide.reducedSteps) || guide.steps;
for (const step of steps) {
if (signal.aborted) return;
await playStep(step, signal);
}
if (signal.aborted) return;
run = undefined;
hideCursor();
root.dataset.guideState = 'complete';
caption.textContent = '导览结束,可以继续手动操作。';
}
function tryAutoplay() {
if (autoPending && inView && !document.hidden) void start();
}
if (typeof IntersectionObserver === 'function') {
const observer = new IntersectionObserver(([entry]) => {
inView = entry.intersectionRatio >= threshold;
if (!entry.isIntersecting && run) stop();
tryAutoplay();
}, { threshold: [0, threshold] });
observer.observe(view);
}
replay.addEventListener('click', () => {
bringViewIntoFrame();
void start(true);
});
const takeOver = (event) => {
if (!event.isTrusted || replay.contains(event.target)) return;
if (!run) {
if (event.type === 'wheel' || !(event.target instanceof Element)) return;
if (!event.target.closest('button, input') && !view.contains(event.target)) return;
}
const pointer = event instanceof PointerEvent && event.pointerType === 'mouse' && releaseTarget?.contains(event.target)
? event : undefined;
stop(pointer);
};
for (const event of ['pointerdown', 'keydown', 'focusin', 'input', 'click', 'wheel']) {
root.addEventListener(event, takeOver, { capture: true, passive: true });
}
if (guide.steps.some(({ action }) => action === 'enter' || action === 'move')) {
view.addEventListener('pointermove', takeOver, { capture: true, passive: true });
}
if (guide.pageScrollInput) {
window.addEventListener('scroll', () => {
if (run && window.scrollY !== playbackScroll) stop();
}, { passive: true });
}
document.addEventListener('visibilitychange', () => {
if (document.hidden && run) stop();
else tryAutoplay();
});
reduced.addEventListener('change', () => {
if (!reduced.matches) return;
const wasIdle = root.dataset.guideState === 'idle';
stop();
if (wasIdle) caption.textContent = reducedCaption;
});
}
bindDemoGuide(document.body, {"view":"[data-transition-viewport]","reset":"[data-transition-variant]","steps":[{"target":"[data-transition-guide-target]","action":"click","text":"点按作品,追踪同一张图从目录卡片移动到详情。","hold":700}]});
})();
</script>
</body>
</html>GSAP 3.15.0
完整示例,从 CDN 加载指定版本。
打开示例查看源码
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>共享元素转场 · GSAP</title>
<style>
html { background: #fbfaf7; }
body { margin: 2rem auto; padding: 0 1rem; max-width: 38rem; color: #222c36; font: 1rem/1.55 system-ui, sans-serif; }
[data-transition-viewport] { position: relative; isolation: isolate; overflow: hidden; aspect-ratio: 2 / 1; border-radius: 12px; background: #edf3f6; }
[data-transition-view] { position: absolute; inset: 0; padding: 5% 7%; background: #edf3f6; }
[data-transition-view="detail"] { background: #f0ebf5; }
[data-shared-art] { position: absolute; top: 30%; left: 7%; width: 40%; transform-origin: top left; }
[data-transition-view="detail"] [data-shared-art] { top: 25%; left: 39%; width: 54%; }
[data-shared-art] svg { display: block; width: 100%; border-radius: 4px; }
[data-transition-mask] { position: absolute; z-index: 3; inset: 0; background: #6d5581; pointer-events: none; transform: translateX(-100%); }
button { min-height: 2.75rem; margin: .15rem; padding: .35rem .65rem; border: 1px solid #9786a8; border-radius: 6px; background: #fff; color: #222c36; font: inherit; }
button[aria-pressed='true'] { border-color: #6d5581; background: #f0ebf5; }
button:focus-visible { outline: 2px solid #6d5581; outline-offset: 2px; }
[data-transition-status] { color: #574268; }
@media (max-width: 30rem) {
[data-transition-viewport] { aspect-ratio: 1.62 / 1; }
}
[data-transition-view] > .transition-guide-target {
position: absolute;
z-index: 2;
inset: 62% auto auto 55%;
min-block-size: 44px;
margin: 0;
padding: .4rem .75rem;
border: 1px solid var(--transition-accent, #6d5581);
border-radius: 7px;
background: var(--surface, #fff);
color: var(--text, #222c36);
font: inherit;
font-size: .8125rem;
cursor: pointer;
}
.transition-guide-target:is(:hover, :active) { background: var(--transition-tint, #f0ebf5); }
.transition-guide-target:focus-visible { outline: 2px solid var(--transition-accent, #6d5581); outline-offset: 3px; }
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/Flip.min.js"></script>
<style>.demo-guide {
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem .9rem;
min-inline-size: 0;
margin-block: 0 .75rem;
padding-block-end: .5rem;
border-block-end: 1px solid var(--line-hi, #b8c4cd);
color: var(--text-2, #434f5d);
font-size: .8125rem;
line-height: 1.5;
}
.trigger-stage > .demo-guide {
margin: .35rem clamp(.85rem, 2.2vw, 1.4rem) 0;
}
.demo-guide [data-guide-caption] { margin: 0; }
.demo-guide > button[data-guide-replay] {
flex: none;
min-block-size: 44px;
margin: 0;
padding: .35rem .65rem;
border: 1px solid var(--line-strong, #82939f);
border-radius: 7px;
background: var(--surface, #fff);
color: var(--text, #222c36);
font: inherit;
font-weight: 550;
cursor: pointer;
}
.demo-guide > button[data-guide-replay]:hover { border-color: var(--accent-text, #963324); }
.demo-guide > button[data-guide-replay]:focus-visible { outline: 2px solid var(--accent-text, #963324); outline-offset: 2px; }
.demo-guide-cursor {
position: fixed;
z-index: 20;
inset: 0 auto auto 0;
inline-size: 20px;
block-size: 24px;
pointer-events: none;
}
.demo-guide-cursor svg { display: block; inline-size: 100%; block-size: 100%; }
.demo-guide-cursor::before {
position: absolute;
inset: -7px auto auto -7px;
inline-size: 18px;
block-size: 18px;
border: 2px solid var(--accent-text, #963324);
border-radius: 50%;
content: '';
opacity: 0;
}
.demo-guide-cursor[data-press]::before { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .demo-guide-cursor { display: none; } }
</style>
</head>
<body>
<main data-transition-variant="shared-element">
<h1>共享元素转场</h1>
<div data-transition-viewport data-active-view="index">
<div data-transition-view="index" data-view-label="作品目录">
<strong>作品目录</strong>
<div data-shared-art data-flip-id="coastal-art">
<svg viewBox="0 0 220 112" aria-hidden="true" focusable="false">
<rect width="220" height="112" fill="#e8f3fa" />
<path d="M0 94l47-52 26 26 30-35 43 50 26-34 48 36v27H0Z" fill="#628ca8" />
<path d="M0 103l48-52 26 27 31-35 40 49 27-33 48 37" stroke="#39688b" fill="none" />
<circle cx="176" cy="31" r="9" fill="#ef784d" />
</svg>
</div>
<button class="transition-guide-target" type="button" data-transition-action="detail" data-transition-guide-target hidden>打开作品</button>
</div>
<div data-transition-view="detail" data-view-label="作品详情" hidden>
<strong>作品详情</strong>
<div data-shared-art data-flip-id="coastal-art">
<svg viewBox="0 0 220 112" aria-hidden="true" focusable="false">
<rect width="220" height="112" fill="#e8f3fa" />
<path d="M0 94l47-52 26 26 30-35 43 50 26-34 48 36v27H0Z" fill="#628ca8" />
<path d="M0 103l48-52 26 27 31-35 40 49 27-33 48 37" stroke="#39688b" fill="none" />
<circle cx="176" cy="31" r="9" fill="#ef784d" />
</svg>
</div>
</div>
<div data-transition-mask hidden></div>
</div>
<p data-transition-status role="status">当前画面 · 作品目录</p>
<div data-transition-controls hidden>
<button type="button" data-transition-action="index" aria-pressed="true">作品目录</button>
<button type="button" data-transition-action="detail" aria-pressed="false">作品详情</button>
</div>
<noscript><p>起始画面是目录的小图,终止画面是详情的大图。</p></noscript>
</main>
<script>
/** @typedef {{ finished: Promise<void>, stop: () => void }} TransitionMotion */
/** @typedef {{ next: HTMLElement, guided: boolean, motions: TransitionMotion[], styles: Map<HTMLElement, string | null> }} TransitionRun */
/** @param {HTMLElement} element @param {Keyframe[]} frames @param {number} duration @returns {TransitionMotion} */
function animateNative(element, frames, duration) {
const motion = element.animate(frames, {
duration,
easing: 'cubic-bezier(.22, 1, .36, 1)',
fill: 'both',
});
return { finished: motion.finished.then(() => {}, () => {}), stop: () => motion.cancel() };
}
/** @param {HTMLElement} firstArt @param {HTMLElement} lastArt @param {() => void} present */
function flipNative(firstArt, lastArt, present) {
const first = firstArt.getBoundingClientRect();
present();
const last = lastArt.getBoundingClientRect();
if (!first.width || !first.height || !last.width || !last.height) return;
// Measure each endpoint once. Transform the new artwork back to its old pose.
return animateNative(lastArt, [{
transform: `translate(${first.left - last.left}px, ${first.top - last.top}px) scale(${first.width / last.width}, ${first.height / last.height})`,
}, { transform: 'none' }], 560);
}
/** @param {HTMLElement} root @param {typeof animateNative} [animate] @param {typeof flipNative} [flip] */
function bindTransitionStage(root, animate = animateNative, flip = flipNative) {
const viewport = root.querySelector('[data-transition-viewport]');
const controls = root.querySelector('[data-transition-controls]');
const status = root.querySelector('[data-transition-status]');
const mask = root.querySelector('[data-transition-mask]');
const views = Array.from(root.querySelectorAll('[data-transition-view]')).filter((view) => view instanceof HTMLElement);
if (!(viewport instanceof HTMLElement) || !(controls instanceof HTMLElement) || !(mask instanceof HTMLElement) || !status || views.length !== 2) return;
const buttons = Array.from(controls.querySelectorAll('button[data-transition-action]'));
const guideTarget = viewport.querySelector('button[data-transition-guide-target]');
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const variant = root.dataset.transitionVariant;
let active = views[0];
/** @type {TransitionRun | undefined} */
let pending;
/** @param {HTMLElement} view */
const present = (view) => {
for (const candidate of views) {
candidate.hidden = candidate !== view;
if (guideTarget) candidate.setAttribute('aria-hidden', String(candidate.hidden));
}
};
/** @param {HTMLElement} view */
const commit = (view) => {
present(view);
active = view;
mask.hidden = true;
viewport.dataset.activeView = view.dataset.transitionView;
guideTarget?.removeAttribute('disabled');
for (const button of buttons) {
button.removeAttribute('aria-disabled');
button.setAttribute('aria-pressed', String(button.getAttribute('data-transition-action') === view.dataset.transitionView));
}
status.textContent = `当前画面 · ${view.dataset.viewLabel}`;
};
/** @param {TransitionRun} run @param {HTMLElement} [destination] */
const finish = (run, destination = run.next) => {
if (pending !== run) return;
pending = undefined;
for (const motion of run.motions) motion.stop();
for (const [element, style] of run.styles) {
if (style === null) element.removeAttribute('style');
else element.setAttribute('style', style);
}
commit(destination);
};
/** @param {TransitionRun} run @param {HTMLElement} element @param {Keyframe[]} frames @param {number} duration */
const move = (run, element, frames, duration) => {
const motion = animate(element, frames, duration);
run.motions.push(motion);
return motion.finished;
};
/** @param {TransitionRun} run @param {HTMLElement | null} firstArt @param {HTMLElement | null} lastArt */
const play = async (run, firstArt, lastArt) => {
const { next } = run;
const direction = next.dataset.transitionView === 'detail' ? 1 : -1;
if (variant === 'mask-transition') {
mask.hidden = false;
await move(run, mask, [{ transform: `translateX(${-100 * direction}%)` }, { transform: 'translateX(0%)' }], 225);
if (pending !== run) return;
present(next);
await move(run, mask, [{ transform: 'translateX(0%)' }, { transform: `translateX(${100 * direction}%)` }], 275);
} else if (variant === 'overlap-transition') {
next.hidden = false;
next.style.zIndex = '2';
active.style.zIndex = '1';
await Promise.all([
move(run, active, [{ transform: 'translateX(0%)', opacity: 1 }, { transform: `translateX(${-16 * direction}%)`, opacity: .28 }], 470),
move(run, next, [{ transform: `translateX(${27 * direction}%)`, opacity: 0 }, { transform: 'translateX(0%)', opacity: 1 }], 470),
]);
} else if (firstArt && lastArt) {
const motion = flip(firstArt, lastArt, () => present(next));
if (motion) {
run.motions.push(motion);
await motion.finished;
}
}
};
/** @param {HTMLElement} next @param {boolean} [guided] */
const switchTo = (next, guided = false) => {
if (pending) {
if (pending.guided) finish(pending, next);
return;
}
if (next === active) return;
if (reduced.matches || typeof viewport.animate !== 'function') {
commit(next);
return;
}
const firstArt = active.querySelector('[data-shared-art]');
const lastArt = next.querySelector('[data-shared-art]');
const styledElements = [active, next, mask, firstArt, lastArt].filter((element) => element instanceof HTMLElement);
const run = { next, guided, motions: [], styles: new Map(styledElements.map((element) => [element, element.getAttribute('style')])) };
pending = run;
guideTarget?.setAttribute('disabled', '');
if (!guided) {
for (const control of buttons) control.setAttribute('aria-disabled', 'true');
}
status.textContent = `切换至${next.dataset.viewLabel}`;
const before = firstArt instanceof HTMLElement ? firstArt : null;
const after = lastArt instanceof HTMLElement ? lastArt : null;
play(run, before, after).then(() => finish(run), () => finish(run));
};
root.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest('button[data-transition-action]');
if (!button) return;
const destination = button.getAttribute('data-transition-action');
const next = views.find((view) => view.dataset.transitionView === destination);
if (!next) return;
if (button === guideTarget && button === document.activeElement) {
const detailControl = controls.querySelector('button[data-transition-action="detail"]');
if (detailControl instanceof HTMLButtonElement) detailControl.focus({ preventScroll: true });
}
switchTo(next, !event.isTrusted && button === guideTarget && Boolean(root.closest('[data-guide-state="playing"]')));
});
if (guideTarget instanceof HTMLButtonElement) {
guideTarget.hidden = false;
root.addEventListener('formtempo:guide-reset', () => {
if (pending) finish(pending, views[0]);
else commit(views[0]);
});
root.addEventListener('formtempo:guide-cancel', () => {
if (pending?.guided) finish(pending);
});
}
window.addEventListener('resize', () => { if (pending) finish(pending); }, { passive: true });
reduced.addEventListener('change', () => {
if (!reduced.matches) return;
if (pending) finish(pending);
});
commit(active);
controls.hidden = false;
}
function animateWithGsap(element, frames, duration) {
let complete;
const finished = new Promise((resolve) => { complete = resolve; });
const tween = gsap.fromTo(element, frames[0], {
...frames[1], duration: duration / 1000, ease: 'power3.out',
onComplete: complete, onInterrupt: complete,
});
return { finished, stop() { tween.kill(); complete(); } };
}
function flipWithGsap(firstArt, lastArt, present) {
gsap.registerPlugin(Flip);
const state = Flip.getState(firstArt);
present();
let complete;
const finished = new Promise((resolve) => { complete = resolve; });
const tween = Flip.from(state, {
targets: lastArt, scale: true, duration: 0.56, ease: 'power3.out',
onComplete: complete, onInterrupt: complete,
});
return { finished, stop() { tween.kill(); complete(); } };
}
bindTransitionStage(document.querySelector('main'), window.gsap ? animateWithGsap : undefined, window.gsap && window.Flip ? flipWithGsap : undefined);
</script>
<script>
(() => {
/** @param {number} duration @param {AbortSignal} signal @param {(progress: number) => void} [render] */
function guideBeat(duration, signal, render) {
if (signal.aborted) return Promise.resolve();
if (duration === 0) {
render?.(1);
return Promise.resolve();
}
return new Promise((resolve) => {
let timer = 0;
let frame = 0;
const done = () => {
clearTimeout(timer);
cancelAnimationFrame(frame);
signal.removeEventListener('abort', done);
resolve(undefined);
};
signal.addEventListener('abort', done, { once: true });
if (!render) {
timer = window.setTimeout(done, duration);
return;
}
const start = performance.now();
const tick = (now) => {
const progress = Math.min(1, (now - start) / duration);
render(progress);
if (progress === 1) done();
else if (!signal.aborted) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
});
}
/** @param {HTMLElement} root @param {import('./demo-guide-cases').DemoGuide} guide */
function bindDemoGuide(root, guide) {
const view = root.querySelector(guide.view);
if (!view || root.dataset.guideState || guide.steps.some(({ target }) => !root.querySelector(target))) return;
const dock = guide.dock ? root.querySelector(guide.dock) : null;
const bar = document.createElement('div');
bar.className = 'demo-guide';
bar.innerHTML = '<p data-guide-caption></p><button type="button" data-guide-replay>重播导览</button>';
const caption = bar.querySelector('[data-guide-caption]');
const replay = bar.querySelector('button');
const heading = root === document.body ? root.querySelector('h1') : null;
if (dock) dock.prepend(bar);
else if (heading) heading.after(bar);
else root.prepend(bar);
const cursor = document.createElement('div');
cursor.className = 'demo-guide-cursor';
cursor.setAttribute('aria-hidden', 'true');
cursor.hidden = true;
cursor.innerHTML = '<svg viewBox="0 0 20 24" focusable="false"><path d="M2 2v17l4.8-4.5L10 21l3.2-1.6-3.2-6.2 6.6-.7L2 2Z" fill="#222c36" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>';
document.body.append(cursor);
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const threshold = .5;
const idleCaption = '导览在画面进入视口后播放一次。';
const reducedCaption = '可重播关键状态,或直接操作。';
const reset = guide.reset ? (root.matches(guide.reset) ? root : root.querySelector(guide.reset)) : null;
const releaseTarget = guide.release ? root.querySelector(guide.release) : null;
let autoPending = !reduced.matches;
let inView = false;
let playbackScroll = window.scrollY;
/** @type {AbortController | undefined} */
let run;
/** @type {[number, number] | undefined} */
let cursorPosition;
root.dataset.guideState = 'idle';
caption.textContent = reduced.matches ? reducedCaption : idleCaption;
function hideCursor() {
cursor.hidden = true;
delete cursor.dataset.press;
cursorPosition = undefined;
}
/** @param {PointerEvent} [pointer] */
function stop(pointer) {
autoPending = false;
if (!run) return;
run.abort();
run = undefined;
hideCursor();
if (releaseTarget) {
// A real pointer inside the target keeps its hover when the guide yields.
if (pointer) sendPointer(releaseTarget, 'pointerenter', [pointer.clientX, pointer.clientY]);
else sendPointer(releaseTarget, 'pointerleave', [0, 0]);
}
reset?.dispatchEvent(new Event('formtempo:guide-cancel'));
root.dataset.guideState = 'interrupted';
caption.textContent = '导览已停止,可以继续手动操作。';
}
/** @param {[number, number]} position */
function moveCursor(position) {
cursor.hidden = false;
cursor.style.transform = `translate3d(${position[0]}px, ${position[1]}px, 0)`;
cursorPosition = position;
}
/** @param {Element} target @param {readonly [number, number]} point */
function location(target, [x, y]) {
const rect = target.getBoundingClientRect();
return /** @type {[number, number]} */ ([rect.left + rect.width * x, rect.top + rect.height * y]);
}
/** @param {Element} target @param {string} type @param {readonly [number, number]} position */
function sendPointer(target, type, [clientX, clientY]) {
target.dispatchEvent(new PointerEvent(type, { pointerType: 'mouse', clientX, clientY }));
}
/** @param {HTMLInputElement} slider @param {number} value */
function setProgress(slider, value) {
const next = Math.round(value);
if (slider.valueAsNumber === next) return;
slider.value = String(next);
slider.dispatchEvent(new Event('input', { bubbles: true }));
}
/** @param {number} value */
function showReducedProgress(value) {
const options = Array.from(root.querySelectorAll('button[data-driven-step], button[data-scale-step], button[data-progress-step], button[data-progress]'));
const percent = (button) => button.hasAttribute('data-progress')
? Number(button.getAttribute('data-progress')) * 100
: Number(button.getAttribute('data-driven-step') ?? button.getAttribute('data-scale-step') ?? button.getAttribute('data-progress-step'));
options.sort((a, b) => Math.abs(percent(a) - value) - Math.abs(percent(b) - value));
if (options[0] instanceof HTMLButtonElement) options[0].click();
}
/** @param {import('./demo-guide-cases').DemoGuide['steps'][number]} step @param {AbortSignal} signal */
async function playStep(step, signal) {
const target = root.querySelector(step.target);
if (!target) return;
caption.textContent = step.text;
const { action, value = 0, point = [.5, .5] } = step;
if (action === 'progress' && target instanceof HTMLInputElement) {
if (reduced.matches) {
showReducedProgress(value);
} else {
const from = target.valueAsNumber;
await guideBeat(step.duration ?? 0, signal, (progress) => {
const current = from + (value - from) * progress;
setProgress(target, current);
moveCursor(location(target, [.04 + .92 * current / 100, .65]));
});
}
} else {
if (!reduced.matches) {
const to = location(target, point);
const [x, y] = point;
const entry = x < .25 ? [-.08, y] : x > .75 ? [1.08, y] : [x, y < .5 ? -.08 : 1.08];
const from = action === 'enter' ? location(target, /** @type {[number, number]} */ (entry))
: cursorPosition ?? [to[0] + 26, to[1] + 19];
const duration = action === 'leave' ? 200 : 340;
await guideBeat(duration, signal, (progress) => {
const t = 1 - (1 - progress) ** 3;
const end = location(target, point);
const position = /** @type {[number, number]} */ ([
from[0] + (end[0] - from[0]) * t,
from[1] + (end[1] - from[1]) * t,
]);
moveCursor(position);
if (action === 'move') sendPointer(target, 'pointermove', position);
});
}
if (signal.aborted) return;
if (action === 'click' && target instanceof HTMLElement) {
if (!reduced.matches) {
cursor.dataset.press = '';
await guideBeat(125, signal);
if (signal.aborted) return;
}
target.click();
delete cursor.dataset.press;
} else if (action === 'enter' || action === 'leave') {
sendPointer(target, `pointer${action}`, location(target, point));
}
}
await guideBeat(reduced.matches ? 600 : step.hold ?? 500, signal);
}
function bringViewIntoFrame() {
const rect = view.getBoundingClientRect();
const visibleHeight = Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0);
if (visibleHeight < rect.height * threshold) view.scrollIntoView({ block: 'center', behavior: 'instant' });
}
async function start(replaying = false) {
stop();
const controller = new AbortController();
const { signal } = controller;
run = controller;
root.dataset.guideState = 'playing';
if (guide.ready) {
caption.textContent = '正在等待视频就绪…';
const deadline = performance.now() + 4000;
while (!signal.aborted && !root.querySelector(guide.ready) && performance.now() < deadline &&
!root.querySelector('[data-video-state="error"]')) {
await guideBeat(90, signal);
}
if (signal.aborted) return;
if (!root.querySelector(guide.ready)) {
run = undefined;
root.dataset.guideState = 'unavailable';
caption.textContent = '视频暂未就绪,可以查看关键帧或重播。';
return;
}
}
reset?.dispatchEvent(new Event('formtempo:guide-reset'));
if (replaying) bringViewIntoFrame();
playbackScroll = window.scrollY;
const steps = (reduced.matches && guide.reducedSteps) || guide.steps;
for (const step of steps) {
if (signal.aborted) return;
await playStep(step, signal);
}
if (signal.aborted) return;
run = undefined;
hideCursor();
root.dataset.guideState = 'complete';
caption.textContent = '导览结束,可以继续手动操作。';
}
function tryAutoplay() {
if (autoPending && inView && !document.hidden) void start();
}
if (typeof IntersectionObserver === 'function') {
const observer = new IntersectionObserver(([entry]) => {
inView = entry.intersectionRatio >= threshold;
if (!entry.isIntersecting && run) stop();
tryAutoplay();
}, { threshold: [0, threshold] });
observer.observe(view);
}
replay.addEventListener('click', () => {
bringViewIntoFrame();
void start(true);
});
const takeOver = (event) => {
if (!event.isTrusted || replay.contains(event.target)) return;
if (!run) {
if (event.type === 'wheel' || !(event.target instanceof Element)) return;
if (!event.target.closest('button, input') && !view.contains(event.target)) return;
}
const pointer = event instanceof PointerEvent && event.pointerType === 'mouse' && releaseTarget?.contains(event.target)
? event : undefined;
stop(pointer);
};
for (const event of ['pointerdown', 'keydown', 'focusin', 'input', 'click', 'wheel']) {
root.addEventListener(event, takeOver, { capture: true, passive: true });
}
if (guide.steps.some(({ action }) => action === 'enter' || action === 'move')) {
view.addEventListener('pointermove', takeOver, { capture: true, passive: true });
}
if (guide.pageScrollInput) {
window.addEventListener('scroll', () => {
if (run && window.scrollY !== playbackScroll) stop();
}, { passive: true });
}
document.addEventListener('visibilitychange', () => {
if (document.hidden && run) stop();
else tryAutoplay();
});
reduced.addEventListener('change', () => {
if (!reduced.matches) return;
const wasIdle = root.dataset.guideState === 'idle';
stop();
if (wasIdle) caption.textContent = reducedCaption;
});
}
bindDemoGuide(document.body, {"view":"[data-transition-viewport]","reset":"[data-transition-variant]","steps":[{"target":"[data-transition-guide-target]","action":"click","text":"点按作品,追踪同一张图从目录卡片移动到详情。","hold":700}]});
})();
</script>
</body>
</html>