单次入场
首次向下进入时播放,之后保持揭示。
触发起点
越过边界,触发变化
已揭示- 起点前
- 区间内
- 终点后
滚动页面,或逐项试按四个事件。
起始状态揭示状态
四个边界事件
「—」表示保留当前状态。加载在区间内时三个案例均已揭示。加载在区间后时,仅双向揭示回到初始状态。
| 行为 | 向下进入 | 向下离开 | 向上回进 | 向上离开 |
|---|---|---|---|---|
| 单次入场 | 播放 | — | — | — |
| 单向可逆 | 播放 | — | — | 复位 |
| 双向揭示 | 播放 | 复位 | 播放 | 复位 |
使用场景
长文小节。例如报道第一次读到图解时让图例显现,读者回看时可直接继续阅读。
行为定义
向下穿过起点播放并记录已播放。向上返回或继续向下离开都保留完成状态。
实现要点
原生记录一次性状态,GSAP 使用 once: 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>单次入场 · FormTempo</title>
<style>
html { font: 1rem/1.5 system-ui, sans-serif; background: #fbfaf7; color: #222c36; }
body { margin: 0; }
main { max-width: 43rem; margin: 0 auto; padding: 1rem; }
.prelude { min-height: 22vh; }
.track { position: relative; padding-bottom: 18rem; }
.mark { position: absolute; top: var(--top); scroll-margin-top: 0; }
#origin { top: 0; z-index: 1; }
#origin:not(:focus) { clip-path: inset(50%); }
.stage { position: sticky; top: 1rem; padding: 1rem; border: 1px solid #d1c5ac;
border-radius: .7rem; background: #fff; }
.window { display: grid; place-items: center; height: 8rem; overflow: hidden;
border: 1px dashed #876221; background: #f7efd9; }
#panel { display: grid; place-items: center; width: 80%; height: 5.5rem;
background: #876221; color: #fffdf8; font-size: 1.25rem; font-weight: 700; }
[data-engine="native"] #panel { transition: opacity .35s, transform .38s; }
[data-engine="native"][data-revealed="false"] #panel { opacity: 0; transform: translateY(.75rem); }
[data-engine="native"][data-syncing] #panel { transition: none; }
button { font: inherit; padding: .3rem .55rem; margin: .3rem .15rem .2rem 0; border: 1px solid #978451; border-radius: 6px; background: #fff; color: #222c36; }
:focus-visible { outline: 2px solid #876221; outline-offset: 2px; }
.outro { min-height: 65vh; }
@media (prefers-reduced-motion: reduce) {
[data-engine="native"] #panel { transition: none; }
}
</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 data-engine="native">
<main>
<div class="prelude">
<h1>单次入场</h1>
<p>向下滚动越过两个边界,然后向上回滚。也可以直接试按四个事件。</p>
<a href="#inside">定位区间内</a> · <a href="#after">定位终点后</a>
</div>
<div class="track">
<span class="mark" id="origin" tabindex="-1">触发起点</span>
<i class="mark" id="start" style="--top: 4rem" aria-hidden="true"></i>
<i class="mark" id="inside" style="--top: 9rem" aria-hidden="true"></i>
<i class="mark" id="end" style="--top: 14rem" aria-hidden="true"></i>
<i class="mark" id="after" style="--top: 17rem" aria-hidden="true"></i>
<section class="stage" aria-label="演示">
<div class="window"><div id="panel">揭示内容</div></div>
<div id="buttons"><button type="button" data-event="0">向下进入</button><button type="button" data-event="1">向下离开</button><button type="button" data-event="2">向上回进</button><button type="button" data-event="3">向上离开</button></div>
<button type="button" id="reset">回到触发起点</button>
<span id="status" aria-live="polite">揭示状态</span>
</section>
</div>
<p class="outro">终点之后。向上滚动可比较返回时的行为。</p>
</main>
<script>
const variant = "enter-once";
const actions = ["play","none","none","none"];
const origin = document.querySelector('#origin');
const start = document.querySelector('#start');
const end = document.querySelector('#end');
const panel = document.querySelector('#panel');
const status = document.querySelector('#status');
const buttons = document.querySelector('#buttons');
const reset = document.querySelector('#reset');
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
let shown = false;
let played = false;
let suspended = false;
let guideSnapshot;
function startGuideSimulation() {
guideSnapshot ??= { shown, played, scroll: window.scrollY };
guideSnapshot.scroll = window.scrollY;
played = false;
shown = false;
}
function resumePageScroll() {
if (!guideSnapshot || guideSnapshot.scroll === window.scrollY) return false;
({ shown, played } = guideSnapshot);
guideSnapshot = undefined;
return true;
}
const topOf = (marker) => window.scrollY + marker.getBoundingClientRect().top;
const regionAt = (scroll) => scroll <= startY ? 0 : scroll >= endY ? 2 : 1;
const measure = () => {
startY = topOf(start);
endY = topOf(end);
};
let startY = 0;
let endY = 0;
function setInitial(region) {
guideSnapshot = undefined;
shown = region === 1 || (region === 2 && variant !== 'bidirectional-reveal');
played = variant === 'enter-once' && shown;
}
function apply(event) {
if (actions[event] === 'play' && !(variant === 'enter-once' && played)) {
shown = true;
played = true;
}
if (actions[event] === 'reset') shown = false;
}
function publish() {
document.body.dataset.revealed = String(shown);
status.textContent = shown ? '已揭示' : '初始状态';
}
function moveToOrigin(complete) {
const destination = Math.max(0, Math.min(Math.floor(topOf(origin)), Math.floor(startY) - 1));
window.scrollTo({ top: destination, behavior: 'instant' });
if (window.scrollY <= startY) complete();
else requestAnimationFrame(complete);
}
function focusOrigin() {
origin.focus({ preventScroll: true });
}
let previous;
let scrollFrame = 0;
let syncFrame = 0;
function placeWithoutMotion() {
document.body.dataset.syncing = '';
publish();
requestAnimationFrame(() => delete document.body.dataset.syncing);
}
function seed() {
if (suspended) return;
measure();
previous = regionAt(window.scrollY);
setInitial(previous);
placeWithoutMotion();
}
function scheduleSeed() {
cancelAnimationFrame(syncFrame);
cancelAnimationFrame(scrollFrame);
scrollFrame = 0;
syncFrame = requestAnimationFrame(seed);
}
function updateScroll() {
if (suspended || scrollFrame) return;
scrollFrame = requestAnimationFrame(() => {
scrollFrame = 0;
const current = regionAt(window.scrollY);
resumePageScroll();
// Apply both events in order when a fast scroll crosses both boundaries.
for (let edge = previous; edge < current; edge++) apply(edge);
for (let edge = previous; edge > current; edge--) apply(4 - edge);
previous = current;
publish();
});
}
buttons.addEventListener('click', (event) => {
const button = event.target.closest('[data-event]');
if (!button || suspended) return;
apply(Number(button.dataset.event));
publish();
});
document.body.addEventListener('formtempo:guide-reset', () => {
startGuideSimulation();
placeWithoutMotion();
});
reset.addEventListener('click', () => {
if (suspended) return;
guideSnapshot = undefined;
suspended = true;
cancelAnimationFrame(syncFrame);
cancelAnimationFrame(scrollFrame);
scrollFrame = 0;
measure();
moveToOrigin(() => {
measure();
previous = regionAt(window.scrollY);
suspended = false;
if (previous !== 0) return;
played = false;
shown = false;
placeWithoutMotion();
focusOrigin();
});
});
seed();
addEventListener('scroll', updateScroll, { passive: true });
addEventListener('resize', measure);
addEventListener('pageshow', scheduleSeed);
addEventListener('hashchange', scheduleSeed);
</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":".trigger-composition, .stage .window","dock":".trigger-stage, .track .stage","reset":"[data-trigger-variant], body[data-engine]","pageScrollInput":true,"steps":[{"target":"[data-trigger-event=\"0\"], [data-event=\"0\"]","action":"click","text":"向下进入区间,首次揭示。","hold":470},{"target":"[data-trigger-event=\"1\"], [data-event=\"1\"]","action":"click","text":"从下方离开,内容仍然可见。","hold":470},{"target":"[data-trigger-event=\"2\"], [data-event=\"2\"]","action":"click","text":"向上回到区间,保留已揭示状态。","hold":470},{"target":"[data-trigger-event=\"3\"], [data-event=\"3\"]","action":"click","text":"退回起点前,单次入场仍然保持完成。","hold":470}]});
})();
</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>单次入场 · FormTempo</title>
<style>
html { font: 1rem/1.5 system-ui, sans-serif; background: #fbfaf7; color: #222c36; }
body { margin: 0; }
main { max-width: 43rem; margin: 0 auto; padding: 1rem; }
.prelude { min-height: 22vh; }
.track { position: relative; padding-bottom: 18rem; }
.mark { position: absolute; top: var(--top); scroll-margin-top: 0; }
#origin { top: 0; z-index: 1; }
#origin:not(:focus) { clip-path: inset(50%); }
.stage { position: sticky; top: 1rem; padding: 1rem; border: 1px solid #d1c5ac;
border-radius: .7rem; background: #fff; }
.window { display: grid; place-items: center; height: 8rem; overflow: hidden;
border: 1px dashed #876221; background: #f7efd9; }
#panel { display: grid; place-items: center; width: 80%; height: 5.5rem;
background: #876221; color: #fffdf8; font-size: 1.25rem; font-weight: 700; }
[data-engine="native"] #panel { transition: opacity .35s, transform .38s; }
[data-engine="native"][data-revealed="false"] #panel { opacity: 0; transform: translateY(.75rem); }
[data-engine="native"][data-syncing] #panel { transition: none; }
button { font: inherit; padding: .3rem .55rem; margin: .3rem .15rem .2rem 0; border: 1px solid #978451; border-radius: 6px; background: #fff; color: #222c36; }
:focus-visible { outline: 2px solid #876221; outline-offset: 2px; }
.outro { min-height: 65vh; }
@media (prefers-reduced-motion: reduce) {
[data-engine="native"] #panel { transition: none; }
}
</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 data-engine="gsap">
<main>
<div class="prelude">
<h1>单次入场</h1>
<p>向下滚动越过两个边界,然后向上回滚。也可以直接试按四个事件。</p>
<a href="#inside">定位区间内</a> · <a href="#after">定位终点后</a>
</div>
<div class="track">
<span class="mark" id="origin" tabindex="-1">触发起点</span>
<i class="mark" id="start" style="--top: 4rem" aria-hidden="true"></i>
<i class="mark" id="inside" style="--top: 9rem" aria-hidden="true"></i>
<i class="mark" id="end" style="--top: 14rem" aria-hidden="true"></i>
<i class="mark" id="after" style="--top: 17rem" aria-hidden="true"></i>
<section class="stage" aria-label="演示">
<div class="window"><div id="panel">揭示内容</div></div>
<div id="buttons"><button type="button" data-event="0">向下进入</button><button type="button" data-event="1">向下离开</button><button type="button" data-event="2">向上回进</button><button type="button" data-event="3">向上离开</button></div>
<button type="button" id="reset">回到触发起点</button>
<span id="status" aria-live="polite">揭示状态</span>
</section>
</div>
<p class="outro">终点之后。向上滚动可比较返回时的行为。</p>
</main>
<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/ScrollTrigger.min.js"></script>
<script>
const variant = "enter-once";
const actions = ["play","none","none","none"];
const origin = document.querySelector('#origin');
const start = document.querySelector('#start');
const end = document.querySelector('#end');
const panel = document.querySelector('#panel');
const status = document.querySelector('#status');
const buttons = document.querySelector('#buttons');
const reset = document.querySelector('#reset');
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
let shown = false;
let played = false;
let suspended = false;
let guideSnapshot;
function startGuideSimulation() {
guideSnapshot ??= { shown, played, scroll: window.scrollY };
guideSnapshot.scroll = window.scrollY;
played = false;
shown = false;
}
function resumePageScroll() {
if (!guideSnapshot || guideSnapshot.scroll === window.scrollY) return false;
({ shown, played } = guideSnapshot);
guideSnapshot = undefined;
return true;
}
const topOf = (marker) => window.scrollY + marker.getBoundingClientRect().top;
const regionAt = (scroll) => scroll <= startY ? 0 : scroll >= endY ? 2 : 1;
const measure = () => {
startY = topOf(start);
endY = topOf(end);
};
let startY = 0;
let endY = 0;
function setInitial(region) {
guideSnapshot = undefined;
shown = region === 1 || (region === 2 && variant !== 'bidirectional-reveal');
played = variant === 'enter-once' && shown;
}
function apply(event) {
if (actions[event] === 'play' && !(variant === 'enter-once' && played)) {
shown = true;
played = true;
}
if (actions[event] === 'reset') shown = false;
}
function publish() {
document.body.dataset.revealed = String(shown);
status.textContent = shown ? '已揭示' : '初始状态';
}
function moveToOrigin(complete) {
const destination = Math.max(0, Math.min(Math.floor(topOf(origin)), Math.floor(startY) - 1));
window.scrollTo({ top: destination, behavior: 'instant' });
if (window.scrollY <= startY) complete();
else requestAnimationFrame(complete);
}
function focusOrigin() {
origin.focus({ preventScroll: true });
}
gsap.registerPlugin(ScrollTrigger);
const tween = gsap.fromTo(panel, { opacity: 0, y: 12 }, {
opacity: 1,
y: 0,
duration: .38,
ease: 'power2.out',
paused: true,
});
let trigger;
let syncing = false;
let syncFrame = 0;
function onBoundary(event) {
if (syncing || suspended) return;
const resuming = resumePageScroll();
const wasShown = shown;
apply(event);
if (reducedMotion.matches) tween.progress(shown ? 1 : 0).pause();
else if (resuming) {
tween.progress(wasShown ? 1 : 0).pause();
if (shown && !wasShown) tween.play();
else if (!shown) tween.progress(0).pause();
}
publish();
}
function attachTrigger() {
// A restored position inside or after the interval has already consumed
// the one-shot. A reset creates a new trigger from before the interval.
if (variant === 'enter-once' && played) return;
trigger = ScrollTrigger.create({
trigger: start,
start: () => topOf(start),
end: () => topOf(end),
animation: tween,
toggleActions: actions.join(' '),
once: variant === 'enter-once',
onEnter: () => onBoundary(0),
onLeave: () => onBoundary(1),
onEnterBack: () => onBoundary(2),
onLeaveBack: () => onBoundary(3),
});
}
function stopTrigger() {
// once:true can also remove the trigger, so always create a fresh instance.
trigger?.kill(false, true);
trigger = undefined;
}
function seed() {
if (suspended) return;
syncing = true;
stopTrigger();
measure();
setInitial(regionAt(window.scrollY));
attachTrigger();
tween.progress(shown ? 1 : 0).pause();
publish();
syncing = false;
}
function scheduleSeed() {
cancelAnimationFrame(syncFrame);
syncFrame = requestAnimationFrame(seed);
}
buttons.addEventListener('click', (event) => {
const button = event.target.closest('[data-event]');
if (!button || suspended) return;
const previous = shown;
apply(Number(button.dataset.event));
if (reducedMotion.matches || !shown) tween.progress(shown ? 1 : 0).pause();
else if (!previous) tween.play();
publish();
});
document.body.addEventListener('formtempo:guide-reset', () => {
startGuideSimulation();
tween.progress(0).pause();
publish();
});
reset.addEventListener('click', () => {
if (suspended) return;
guideSnapshot = undefined;
suspended = true;
cancelAnimationFrame(syncFrame);
stopTrigger();
measure();
moveToOrigin(() => {
measure();
if (regionAt(window.scrollY) !== 0) {
suspended = false;
seed();
return;
}
played = false;
shown = false;
syncing = true;
attachTrigger();
tween.progress(0).pause();
publish();
focusOrigin();
syncing = false;
suspended = false;
});
});
seed();
addEventListener('scrollend', () => {
if (resumePageScroll()) {
tween.progress(shown ? 1 : 0).pause();
publish();
}
});
addEventListener('pageshow', scheduleSeed);
addEventListener('hashchange', scheduleSeed);
reducedMotion.addEventListener('change', () => tween.progress(shown ? 1 : 0).pause());
</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":".trigger-composition, .stage .window","dock":".trigger-stage, .track .stage","reset":"[data-trigger-variant], body[data-engine]","pageScrollInput":true,"steps":[{"target":"[data-trigger-event=\"0\"], [data-event=\"0\"]","action":"click","text":"向下进入区间,首次揭示。","hold":470},{"target":"[data-trigger-event=\"1\"], [data-event=\"1\"]","action":"click","text":"从下方离开,内容仍然可见。","hold":470},{"target":"[data-trigger-event=\"2\"], [data-event=\"2\"]","action":"click","text":"向上回到区间,保留已揭示状态。","hold":470},{"target":"[data-trigger-event=\"3\"], [data-event=\"3\"]","action":"click","text":"退回起点前,单次入场仍然保持完成。","hold":470}]});
})();
</script>
</body>
</html>