滚动软边遮罩

同一幅图向四边展开,对照清晰裁剪线和渐隐边缘。

18 / 22Soft mask reveal打开示例
硬边裁剪边界内完整可见
软边遮罩边缘渐隐,过渡到透明
  1. 使用场景

    作品或产品图的显现。例如背景需要从画面边缘逐渐透出,衔接上下文。

  2. 行为定义

    两幅图共用进度和展开范围。硬边在裁剪线处截断,软边在同一位置附近逐渐变透明。

  3. 实现要点

    用进度更新四边的裁剪距离。软边在水平与垂直两个方向叠加透明度渐变,使边缘和四角都自然过渡。

原生 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>
<script>
  const theme = new URLSearchParams(location.search).get('theme');
  if (theme === 'dark' || (!theme && matchMedia('(prefers-color-scheme: dark)').matches)) {
    document.documentElement.dataset.theme = 'dark';
  }
</script>
<style>
  :root {
    color-scheme: light;
    --bg: #fbfaf7; --surface: #fff; --surface-2: #edf3f6; --plate: #f7fafb;
    --text: #222c36; --text-2: #434f5d; --line-hi: #b8c4cd; --line-strong: #82939f;
    --accent: #b8412c; --accent-text: #963324;
  }
  :root[data-theme="dark"] {
    color-scheme: dark;
    --bg: #19222d; --surface: #222e3c; --surface-2: #2b3c4d; --plate: #233142;
    --text: #f6f5f1; --text-2: #d8dce1; --line-hi: #576575; --line-strong: #8396a8;
    --accent: #ff9d7c; --accent-text: #ffb399;
  }
  *, *::before, *::after { box-sizing: border-box; }
  body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.55 system-ui, sans-serif; }
  main { max-width: 54rem; margin-inline: auto; padding: clamp(1rem, 4vw, 2rem); }
  h1 { margin: 0 0 1rem; font-size: clamp(1.5rem, 4vw, 2rem); }
  .example-note, .example-fallback { color: var(--text-2); font-size: .875rem; }
  .scroll-progress[data-mode="scroll"] ~ .example-note { min-height: 100vh; }
  .scroll-progress {
  --progress-sticky-top: calc(var(--nav-h, 0px) + 1rem);
  color: var(--text);
}

.scroll-progress__panel {
  min-width: 0;
  padding: clamp(.65rem, 2vw, 1.35rem);
  border: 1px solid var(--line-hi);
  border-radius: 14px;
  background: var(--plate);
}

.scroll-progress__controls { display: grid; gap: .65rem; margin-top: clamp(.8rem, 2vw, 1.2rem); }
.scroll-progress [hidden] { display: none !important; }
.scroll-progress__row {
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr) 4ch;
  align-items: center;
  gap: .5rem clamp(.55rem, 2vw, 1.25rem);
  padding: .2rem clamp(.55rem, 2vw, 1rem);
  border-radius: 10px;
  background: var(--surface-2);
  font-size: .8125rem;
  font-weight: 600;
}

.scroll-progress__row input,
.scroll-progress__row progress {
  width: 100%;
  min-width: 0;
  accent-color: var(--accent);
}

.scroll-progress__row input { height: 44px; cursor: pointer; }
.scroll-progress__row progress { height: .75rem; }
.scroll-progress__value {
  color: var(--accent-text);
  font: 500 .8125rem/1 var(--font-mono, ui-monospace, monospace);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

.scroll-progress__mode, .scroll-progress__steps {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .4rem .65rem;
}

.scroll-progress__mode p { margin: 0; color: var(--text-2); font-size: .8125rem; }
.scroll-progress__controls button {
  min-height: 44px;
  padding: .45rem .72rem;
  border: 1px solid var(--line-hi);
  border-radius: 10px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: .8125rem;
  font-weight: 600;
  cursor: pointer;
}

.scroll-progress__controls button:hover,
.scroll-progress__steps button[aria-pressed="true"] {
  border-color: var(--accent-text);
  color: var(--accent-text);
}

.scroll-progress__controls :is(button, input):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.scroll-progress__distance { display: none; }
.scroll-progress[data-mode="scroll"] .scroll-progress__panel {
  position: sticky;
  top: var(--progress-sticky-top);
  z-index: 1;
}

.scroll-progress[data-mode="scroll"] .scroll-progress__distance {
  display: block;
  height: clamp(22rem, 85vh, 55rem);
}

@media (max-width: 34rem) {
  .scroll-progress__row { grid-template-columns: minmax(0, 1fr) 4ch; padding-block: .35rem; gap: .2rem .55rem; }
  .scroll-progress__row > :first-child { grid-column: 1 / -1; }
  .scroll-progress__steps { gap: .3rem; }
  .scroll-progress__steps button { padding-inline: .45rem; }
}

  .soft-mask-reveal {
  --soft-mask-progress: .5;
  --soft-mask-inset: calc((1 - var(--soft-mask-progress)) * 50%);
  --soft-mask-feather: min(7%, var(--soft-mask-inset), calc(50% - var(--soft-mask-inset)));
  --soft-mask-clear: calc(var(--soft-mask-inset) - var(--soft-mask-feather));
  --soft-mask-solid: calc(var(--soft-mask-inset) + var(--soft-mask-feather));
  --soft-mask-stops: transparent var(--soft-mask-clear), #000 var(--soft-mask-solid),
    #000 calc(100% - var(--soft-mask-solid)), transparent calc(100% - var(--soft-mask-clear));
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(.65rem, 2vw, 1.25rem);
}

.soft-mask-reveal figure { margin: 0; }

.soft-mask-reveal__well {
  position: relative;
  aspect-ratio: 760 / 430;
  overflow: hidden;
  border: 1px solid var(--line-hi);
  border-radius: 8px;
  background: var(--surface-2);
}

.soft-mask-reveal__art,
.soft-mask-reveal__vertical { position: absolute; inset: 0; }

.soft-mask-reveal__art svg { display: block; width: 100%; height: 100%; }

.soft-mask-reveal__art--hard { clip-path: inset(var(--soft-mask-inset)); }

.soft-mask-reveal__art--feather {
  -webkit-mask-image: linear-gradient(to right, var(--soft-mask-stops));
  mask-image: linear-gradient(to right, var(--soft-mask-stops));
}

.soft-mask-reveal__vertical {
  -webkit-mask-image: linear-gradient(to bottom, var(--soft-mask-stops));
  mask-image: linear-gradient(to bottom, var(--soft-mask-stops));
}

.soft-mask-reveal[data-mask-state="empty"] .soft-mask-reveal__art { visibility: hidden; }

.soft-mask-reveal[data-mask-state="full"] .soft-mask-reveal__art--hard { clip-path: none; }

.soft-mask-reveal[data-mask-state="full"] :is(.soft-mask-reveal__art--feather, .soft-mask-reveal__vertical) {
  -webkit-mask-image: none;
  mask-image: none;
}

.soft-mask-reveal figcaption {
  display: grid;
  gap: .18rem;
  padding-block: .65rem .15rem;
  line-height: 1.4;
}

.soft-mask-reveal figcaption strong { font-size: .875rem; font-weight: 650; }
.soft-mask-reveal figcaption span { color: var(--text-2); font-size: .75rem; }

</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>
  <h1>滚动软边遮罩</h1>
  <div class="scroll-progress" data-scroll-progress="soft-mask-reveal" data-mode="manual">
  <div class="scroll-progress__panel" data-progress-panel>
    
<div class="soft-mask-reveal" data-soft-mask-reveal data-mask-state="partial">
  <figure>
    <div class="soft-mask-reveal__well">
      <div class="soft-mask-reveal__art soft-mask-reveal__art--hard" data-soft-mask-hard><svg viewBox="0 0 760 430" aria-hidden="true" focusable="false">
  <path fill="#e8f3fa" d="M0 0h760v430H0z"/>
  <circle cx="454" cy="151" r="77" fill="#ef784d"/>
  <path fill="#f7c98b" d="M0 228 93 198q59-19 127 9l72 30q56 25 112-5l102-57q50-27 103-6l151 63v198H0z"/>
  <path fill="#7299b4" d="m0 294 137-77q54-29 111 5l106 68q40 26 94 9l135-43q89-30 177 11v163H0z"/>
  <path fill="#e5a38a" d="m0 350 128-21q63-11 122 6l95 28q72 22 137-2l110-41q88-33 168 11v99H0z"/>
  <path fill="#adcfdf" d="m0 379 115 9q89 7 174-19l72-22q59-19 119-3l134 34q69 18 146 3v49H0z"/>
  <g fill="none" stroke="#fffdf8" stroke-linecap="round" opacity=".76">
    <path d="M0 403q126 17 274-20m235-21 107 27q76 19 144 5M18 417q114 14 246-20"/>
    <path d="M124 175h43m-12-10h29m377-31h47m-13 12h25" stroke-width="2"/>
  </g>
</svg></div>
    </div>
    <figcaption><strong>硬边裁剪</strong><span>边界内完整可见</span></figcaption>
  </figure>
  <figure>
    <div class="soft-mask-reveal__well">
      <div class="soft-mask-reveal__art soft-mask-reveal__art--feather" data-soft-mask-feather>
        <div class="soft-mask-reveal__vertical"><svg viewBox="0 0 760 430" aria-hidden="true" focusable="false">
  <path fill="#e8f3fa" d="M0 0h760v430H0z"/>
  <circle cx="454" cy="151" r="77" fill="#ef784d"/>
  <path fill="#f7c98b" d="M0 228 93 198q59-19 127 9l72 30q56 25 112-5l102-57q50-27 103-6l151 63v198H0z"/>
  <path fill="#7299b4" d="m0 294 137-77q54-29 111 5l106 68q40 26 94 9l135-43q89-30 177 11v163H0z"/>
  <path fill="#e5a38a" d="m0 350 128-21q63-11 122 6l95 28q72 22 137-2l110-41q88-33 168 11v99H0z"/>
  <path fill="#adcfdf" d="m0 379 115 9q89 7 174-19l72-22q59-19 119-3l134 34q69 18 146 3v49H0z"/>
  <g fill="none" stroke="#fffdf8" stroke-linecap="round" opacity=".76">
    <path d="M0 403q126 17 274-20m235-21 107 27q76 19 144 5M18 417q114 14 246-20"/>
    <path d="M124 175h43m-12-10h29m377-31h47m-13 12h25" stroke-width="2"/>
  </g>
</svg></div>
      </div>
    </div>
    <figcaption><strong>软边遮罩</strong><span>边缘渐隐,过渡到透明</span></figcaption>
  </figure>
</div>
    <div class="scroll-progress__controls" data-progress-controls hidden>
      <div class="scroll-progress__row">
        <span id="soft-mask-reveal-progress-label">演示进度</span>
        <input type="range" min="0" max="100" value="50" aria-labelledby="soft-mask-reveal-progress-label"
          aria-valuetext="50%" data-progress-slider>
        <progress max="100" value="50" aria-labelledby="soft-mask-reveal-progress-label" data-progress-meter hidden></progress>
        <div class="scroll-progress__steps" role="group" aria-labelledby="soft-mask-reveal-progress-label" data-progress-steps hidden>
          <button type="button" data-progress-step="0" aria-pressed="false">0%</button>
          <button type="button" data-progress-step="50" aria-pressed="true">50%</button>
          <button type="button" data-progress-step="100" aria-pressed="false">100%</button>
        </div>
        <span class="scroll-progress__value" data-progress-value aria-hidden="true">50%</span>
      </div>
      <div class="scroll-progress__mode" data-progress-mode>
        <button type="button" data-progress-toggle aria-pressed="false">跟随页面滚动</button>
        <p data-progress-hint hidden>向下滚动前进,向上滚动回退。</p>
      </div>
    </div>
  </div>
  <div class="scroll-progress__distance" aria-hidden="true"></div>
</div>
  
  <p class="example-note">拖动进度,或启用页面滚动来回观察。</p>
</main>

<script>
(() => {
  /** @typedef {() => { start: number, end: number }} MeasureTrack */
/** @typedef {(progress: number) => void} RenderProgress */

/** @param {MeasureTrack} measure @param {RenderProgress} render */
function connectPageScroll(measure, render) {
  let { start, end } = measure();
  let frame = 0;

  function update() {
    if (frame) return;
    frame = requestAnimationFrame(() => {
      frame = 0;
      render((window.scrollY - start) / (end - start));
    });
  }

  window.addEventListener('scroll', update, { passive: true });
  update();
  return {
    refresh() {
      ({ start, end } = measure());
      update();
    },
    destroy() {
      window.removeEventListener('scroll', update);
      cancelAnimationFrame(frame);
    },
  };
}

/** @param {HTMLElement} root @param {RenderProgress} render @param {typeof connectPageScroll} [connectScroll] */
function bindScrollProgress(root, render, connectScroll = connectPageScroll) {
  const panel = root.querySelector('[data-progress-panel]');
  const controls = root.querySelector('[data-progress-controls]');
  const slider = root.querySelector('[data-progress-slider]');
  const meter = root.querySelector('[data-progress-meter]');
  const value = root.querySelector('[data-progress-value]');
  const steps = root.querySelector('[data-progress-steps]');
  const stepButtons = [...steps.querySelectorAll('button')];
  const mode = root.querySelector('[data-progress-mode]');
  const toggle = root.querySelector('[data-progress-toggle]');
  const hint = root.querySelector('[data-progress-hint]');
  const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
  let progress = slider.valueAsNumber / 100;
  let driver;

  function measureTrack() {
    const stickyTop = Number.parseFloat(getComputedStyle(panel).top) || 0;
    const start = root.getBoundingClientRect().top + window.scrollY - stickyTop;
    const distance = Math.max(1, root.offsetHeight - panel.offsetHeight);
    return { start, end: start + distance };
  }

  /** @param {number} next */
  function applyProgress(next) {
    progress = Math.max(0, Math.min(1, next));
    render(progress);
    const percent = Math.round(progress * 100);
    const label = `${percent}%`;
    slider.value = String(percent);
    slider.setAttribute('aria-valuetext', label);
    meter.value = percent;
    value.textContent = label;
    if (motion.matches) {
      for (const button of stepButtons) {
        button.setAttribute('aria-pressed', String(Number(button.dataset.progressStep) === percent));
      }
    }
  }

  function refresh() { driver?.refresh(); }
  const resize = new ResizeObserver(refresh);

  /** @param {boolean} scroll @param {boolean} [keepPanelInView] */
  function setMode(scroll, keepPanelInView = false) {
    const start = driver && keepPanelInView ? measureTrack().start : undefined;
    driver?.destroy();
    driver = undefined;
    resize.disconnect();
    root.dataset.mode = scroll ? 'scroll' : 'manual';
    slider.hidden = scroll || motion.matches;
    meter.hidden = !scroll;
    steps.hidden = !motion.matches;
    mode.hidden = motion.matches;
    hint.hidden = !scroll;
    toggle.setAttribute('aria-pressed', String(scroll));
    toggle.textContent = scroll ? '切回滑杆' : '跟随页面滚动';

    if (scroll) {
      driver = connectScroll(measureTrack, applyProgress);
      resize.observe(panel);
      resize.observe(root);
      resize.observe(document.body);
    } else {
      const increments = motion.matches ? 2 : 100;
      applyProgress(Math.round(progress * increments) / increments);
      if (start !== undefined && window.scrollY > start) {
        window.scrollTo({ top: Math.max(0, start), behavior: 'instant' });
      }
    }
  }

  function syncMotion() {
    const hadFocus = controls.contains(document.activeElement);
    setMode(false, hadFocus);
    if (hadFocus) {
      const control = motion.matches ? steps.querySelector('[aria-pressed="true"]') : slider;
      control?.focus({ preventScroll: true });
    }
  }

  slider.addEventListener('input', () => {
    if (!driver && !motion.matches) applyProgress(slider.valueAsNumber / 100);
  });
  for (const button of stepButtons) {
    button.addEventListener('click', () => applyProgress(Number(button.dataset.progressStep) / 100));
  }
  toggle.addEventListener('click', () => setMode(!driver, true));
  root.addEventListener('formtempo:guide-reset', () => setMode(false));
  window.addEventListener('resize', refresh);
  window.addEventListener('pageshow', refresh);
  motion.addEventListener('change', syncMotion);
  controls.hidden = false;
  syncMotion();
}

  /** @param {HTMLElement} root */
function createSoftMaskRenderer(root) {
  const stage = root.querySelector('[data-soft-mask-reveal]');

  return (progress) => {
    stage.style.setProperty('--soft-mask-progress', String(progress));
    const state = progress === 0 ? 'empty' : progress === 1 ? 'full' : 'partial';
    if (stage.dataset.maskState !== state) stage.dataset.maskState = state;
  };
}

  const root = document.querySelector('[data-scroll-progress]');
  const render = createSoftMaskRenderer(root);
  bindScrollProgress(root, render);
})();
</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-soft-mask-reveal]","dock":"[data-progress-panel]","reset":"[data-scroll-progress]","steps":[{"text":"从关闭的可见区域开始。","target":"input[type=\"range\"]","action":"progress","value":0,"duration":0,"hold":360},{"text":"同样的进度下,比较清晰的裁剪线和渐隐的软边。","target":"input[type=\"range\"]","action":"progress","value":50,"duration":600,"hold":600},{"text":"展开到终点,两边都显示完整画面。","target":"input[type=\"range\"]","action":"progress","value":100,"duration":600,"hold":360},{"text":"回到一半,再看边缘和四角的差别。","target":"input[type=\"range\"]","action":"progress","value":50,"duration":600,"hold":360}]});
})();
</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>
<script>
  const theme = new URLSearchParams(location.search).get('theme');
  if (theme === 'dark' || (!theme && matchMedia('(prefers-color-scheme: dark)').matches)) {
    document.documentElement.dataset.theme = 'dark';
  }
</script>
<style>
  :root {
    color-scheme: light;
    --bg: #fbfaf7; --surface: #fff; --surface-2: #edf3f6; --plate: #f7fafb;
    --text: #222c36; --text-2: #434f5d; --line-hi: #b8c4cd; --line-strong: #82939f;
    --accent: #b8412c; --accent-text: #963324;
  }
  :root[data-theme="dark"] {
    color-scheme: dark;
    --bg: #19222d; --surface: #222e3c; --surface-2: #2b3c4d; --plate: #233142;
    --text: #f6f5f1; --text-2: #d8dce1; --line-hi: #576575; --line-strong: #8396a8;
    --accent: #ff9d7c; --accent-text: #ffb399;
  }
  *, *::before, *::after { box-sizing: border-box; }
  body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.55 system-ui, sans-serif; }
  main { max-width: 54rem; margin-inline: auto; padding: clamp(1rem, 4vw, 2rem); }
  h1 { margin: 0 0 1rem; font-size: clamp(1.5rem, 4vw, 2rem); }
  .example-note, .example-fallback { color: var(--text-2); font-size: .875rem; }
  .scroll-progress[data-mode="scroll"] ~ .example-note { min-height: 100vh; }
  .scroll-progress {
  --progress-sticky-top: calc(var(--nav-h, 0px) + 1rem);
  color: var(--text);
}

.scroll-progress__panel {
  min-width: 0;
  padding: clamp(.65rem, 2vw, 1.35rem);
  border: 1px solid var(--line-hi);
  border-radius: 14px;
  background: var(--plate);
}

.scroll-progress__controls { display: grid; gap: .65rem; margin-top: clamp(.8rem, 2vw, 1.2rem); }
.scroll-progress [hidden] { display: none !important; }
.scroll-progress__row {
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr) 4ch;
  align-items: center;
  gap: .5rem clamp(.55rem, 2vw, 1.25rem);
  padding: .2rem clamp(.55rem, 2vw, 1rem);
  border-radius: 10px;
  background: var(--surface-2);
  font-size: .8125rem;
  font-weight: 600;
}

.scroll-progress__row input,
.scroll-progress__row progress {
  width: 100%;
  min-width: 0;
  accent-color: var(--accent);
}

.scroll-progress__row input { height: 44px; cursor: pointer; }
.scroll-progress__row progress { height: .75rem; }
.scroll-progress__value {
  color: var(--accent-text);
  font: 500 .8125rem/1 var(--font-mono, ui-monospace, monospace);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

.scroll-progress__mode, .scroll-progress__steps {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .4rem .65rem;
}

.scroll-progress__mode p { margin: 0; color: var(--text-2); font-size: .8125rem; }
.scroll-progress__controls button {
  min-height: 44px;
  padding: .45rem .72rem;
  border: 1px solid var(--line-hi);
  border-radius: 10px;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: .8125rem;
  font-weight: 600;
  cursor: pointer;
}

.scroll-progress__controls button:hover,
.scroll-progress__steps button[aria-pressed="true"] {
  border-color: var(--accent-text);
  color: var(--accent-text);
}

.scroll-progress__controls :is(button, input):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.scroll-progress__distance { display: none; }
.scroll-progress[data-mode="scroll"] .scroll-progress__panel {
  position: sticky;
  top: var(--progress-sticky-top);
  z-index: 1;
}

.scroll-progress[data-mode="scroll"] .scroll-progress__distance {
  display: block;
  height: clamp(22rem, 85vh, 55rem);
}

@media (max-width: 34rem) {
  .scroll-progress__row { grid-template-columns: minmax(0, 1fr) 4ch; padding-block: .35rem; gap: .2rem .55rem; }
  .scroll-progress__row > :first-child { grid-column: 1 / -1; }
  .scroll-progress__steps { gap: .3rem; }
  .scroll-progress__steps button { padding-inline: .45rem; }
}

  .soft-mask-reveal {
  --soft-mask-progress: .5;
  --soft-mask-inset: calc((1 - var(--soft-mask-progress)) * 50%);
  --soft-mask-feather: min(7%, var(--soft-mask-inset), calc(50% - var(--soft-mask-inset)));
  --soft-mask-clear: calc(var(--soft-mask-inset) - var(--soft-mask-feather));
  --soft-mask-solid: calc(var(--soft-mask-inset) + var(--soft-mask-feather));
  --soft-mask-stops: transparent var(--soft-mask-clear), #000 var(--soft-mask-solid),
    #000 calc(100% - var(--soft-mask-solid)), transparent calc(100% - var(--soft-mask-clear));
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(.65rem, 2vw, 1.25rem);
}

.soft-mask-reveal figure { margin: 0; }

.soft-mask-reveal__well {
  position: relative;
  aspect-ratio: 760 / 430;
  overflow: hidden;
  border: 1px solid var(--line-hi);
  border-radius: 8px;
  background: var(--surface-2);
}

.soft-mask-reveal__art,
.soft-mask-reveal__vertical { position: absolute; inset: 0; }

.soft-mask-reveal__art svg { display: block; width: 100%; height: 100%; }

.soft-mask-reveal__art--hard { clip-path: inset(var(--soft-mask-inset)); }

.soft-mask-reveal__art--feather {
  -webkit-mask-image: linear-gradient(to right, var(--soft-mask-stops));
  mask-image: linear-gradient(to right, var(--soft-mask-stops));
}

.soft-mask-reveal__vertical {
  -webkit-mask-image: linear-gradient(to bottom, var(--soft-mask-stops));
  mask-image: linear-gradient(to bottom, var(--soft-mask-stops));
}

.soft-mask-reveal[data-mask-state="empty"] .soft-mask-reveal__art { visibility: hidden; }

.soft-mask-reveal[data-mask-state="full"] .soft-mask-reveal__art--hard { clip-path: none; }

.soft-mask-reveal[data-mask-state="full"] :is(.soft-mask-reveal__art--feather, .soft-mask-reveal__vertical) {
  -webkit-mask-image: none;
  mask-image: none;
}

.soft-mask-reveal figcaption {
  display: grid;
  gap: .18rem;
  padding-block: .65rem .15rem;
  line-height: 1.4;
}

.soft-mask-reveal figcaption strong { font-size: .875rem; font-weight: 650; }
.soft-mask-reveal figcaption span { color: var(--text-2); font-size: .75rem; }

</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>
  <h1>滚动软边遮罩</h1>
  <div class="scroll-progress" data-scroll-progress="soft-mask-reveal" data-mode="manual">
  <div class="scroll-progress__panel" data-progress-panel>
    
<div class="soft-mask-reveal" data-soft-mask-reveal data-mask-state="partial">
  <figure>
    <div class="soft-mask-reveal__well">
      <div class="soft-mask-reveal__art soft-mask-reveal__art--hard" data-soft-mask-hard><svg viewBox="0 0 760 430" aria-hidden="true" focusable="false">
  <path fill="#e8f3fa" d="M0 0h760v430H0z"/>
  <circle cx="454" cy="151" r="77" fill="#ef784d"/>
  <path fill="#f7c98b" d="M0 228 93 198q59-19 127 9l72 30q56 25 112-5l102-57q50-27 103-6l151 63v198H0z"/>
  <path fill="#7299b4" d="m0 294 137-77q54-29 111 5l106 68q40 26 94 9l135-43q89-30 177 11v163H0z"/>
  <path fill="#e5a38a" d="m0 350 128-21q63-11 122 6l95 28q72 22 137-2l110-41q88-33 168 11v99H0z"/>
  <path fill="#adcfdf" d="m0 379 115 9q89 7 174-19l72-22q59-19 119-3l134 34q69 18 146 3v49H0z"/>
  <g fill="none" stroke="#fffdf8" stroke-linecap="round" opacity=".76">
    <path d="M0 403q126 17 274-20m235-21 107 27q76 19 144 5M18 417q114 14 246-20"/>
    <path d="M124 175h43m-12-10h29m377-31h47m-13 12h25" stroke-width="2"/>
  </g>
</svg></div>
    </div>
    <figcaption><strong>硬边裁剪</strong><span>边界内完整可见</span></figcaption>
  </figure>
  <figure>
    <div class="soft-mask-reveal__well">
      <div class="soft-mask-reveal__art soft-mask-reveal__art--feather" data-soft-mask-feather>
        <div class="soft-mask-reveal__vertical"><svg viewBox="0 0 760 430" aria-hidden="true" focusable="false">
  <path fill="#e8f3fa" d="M0 0h760v430H0z"/>
  <circle cx="454" cy="151" r="77" fill="#ef784d"/>
  <path fill="#f7c98b" d="M0 228 93 198q59-19 127 9l72 30q56 25 112-5l102-57q50-27 103-6l151 63v198H0z"/>
  <path fill="#7299b4" d="m0 294 137-77q54-29 111 5l106 68q40 26 94 9l135-43q89-30 177 11v163H0z"/>
  <path fill="#e5a38a" d="m0 350 128-21q63-11 122 6l95 28q72 22 137-2l110-41q88-33 168 11v99H0z"/>
  <path fill="#adcfdf" d="m0 379 115 9q89 7 174-19l72-22q59-19 119-3l134 34q69 18 146 3v49H0z"/>
  <g fill="none" stroke="#fffdf8" stroke-linecap="round" opacity=".76">
    <path d="M0 403q126 17 274-20m235-21 107 27q76 19 144 5M18 417q114 14 246-20"/>
    <path d="M124 175h43m-12-10h29m377-31h47m-13 12h25" stroke-width="2"/>
  </g>
</svg></div>
      </div>
    </div>
    <figcaption><strong>软边遮罩</strong><span>边缘渐隐,过渡到透明</span></figcaption>
  </figure>
</div>
    <div class="scroll-progress__controls" data-progress-controls hidden>
      <div class="scroll-progress__row">
        <span id="soft-mask-reveal-progress-label">演示进度</span>
        <input type="range" min="0" max="100" value="50" aria-labelledby="soft-mask-reveal-progress-label"
          aria-valuetext="50%" data-progress-slider>
        <progress max="100" value="50" aria-labelledby="soft-mask-reveal-progress-label" data-progress-meter hidden></progress>
        <div class="scroll-progress__steps" role="group" aria-labelledby="soft-mask-reveal-progress-label" data-progress-steps hidden>
          <button type="button" data-progress-step="0" aria-pressed="false">0%</button>
          <button type="button" data-progress-step="50" aria-pressed="true">50%</button>
          <button type="button" data-progress-step="100" aria-pressed="false">100%</button>
        </div>
        <span class="scroll-progress__value" data-progress-value aria-hidden="true">50%</span>
      </div>
      <div class="scroll-progress__mode" data-progress-mode>
        <button type="button" data-progress-toggle aria-pressed="false">跟随页面滚动</button>
        <p data-progress-hint hidden>向下滚动前进,向上滚动回退。</p>
      </div>
    </div>
  </div>
  <div class="scroll-progress__distance" aria-hidden="true"></div>
</div>
  <p class="example-fallback" data-gsap-fallback role="status" hidden>GSAP 未加载,当前使用原生滚动。</p>
  <p class="example-note">拖动进度,或启用页面滚动来回观察。</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>
(() => {
  /** @typedef {() => { start: number, end: number }} MeasureTrack */
/** @typedef {(progress: number) => void} RenderProgress */

/** @param {MeasureTrack} measure @param {RenderProgress} render */
function connectPageScroll(measure, render) {
  let { start, end } = measure();
  let frame = 0;

  function update() {
    if (frame) return;
    frame = requestAnimationFrame(() => {
      frame = 0;
      render((window.scrollY - start) / (end - start));
    });
  }

  window.addEventListener('scroll', update, { passive: true });
  update();
  return {
    refresh() {
      ({ start, end } = measure());
      update();
    },
    destroy() {
      window.removeEventListener('scroll', update);
      cancelAnimationFrame(frame);
    },
  };
}

/** @param {HTMLElement} root @param {RenderProgress} render @param {typeof connectPageScroll} [connectScroll] */
function bindScrollProgress(root, render, connectScroll = connectPageScroll) {
  const panel = root.querySelector('[data-progress-panel]');
  const controls = root.querySelector('[data-progress-controls]');
  const slider = root.querySelector('[data-progress-slider]');
  const meter = root.querySelector('[data-progress-meter]');
  const value = root.querySelector('[data-progress-value]');
  const steps = root.querySelector('[data-progress-steps]');
  const stepButtons = [...steps.querySelectorAll('button')];
  const mode = root.querySelector('[data-progress-mode]');
  const toggle = root.querySelector('[data-progress-toggle]');
  const hint = root.querySelector('[data-progress-hint]');
  const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
  let progress = slider.valueAsNumber / 100;
  let driver;

  function measureTrack() {
    const stickyTop = Number.parseFloat(getComputedStyle(panel).top) || 0;
    const start = root.getBoundingClientRect().top + window.scrollY - stickyTop;
    const distance = Math.max(1, root.offsetHeight - panel.offsetHeight);
    return { start, end: start + distance };
  }

  /** @param {number} next */
  function applyProgress(next) {
    progress = Math.max(0, Math.min(1, next));
    render(progress);
    const percent = Math.round(progress * 100);
    const label = `${percent}%`;
    slider.value = String(percent);
    slider.setAttribute('aria-valuetext', label);
    meter.value = percent;
    value.textContent = label;
    if (motion.matches) {
      for (const button of stepButtons) {
        button.setAttribute('aria-pressed', String(Number(button.dataset.progressStep) === percent));
      }
    }
  }

  function refresh() { driver?.refresh(); }
  const resize = new ResizeObserver(refresh);

  /** @param {boolean} scroll @param {boolean} [keepPanelInView] */
  function setMode(scroll, keepPanelInView = false) {
    const start = driver && keepPanelInView ? measureTrack().start : undefined;
    driver?.destroy();
    driver = undefined;
    resize.disconnect();
    root.dataset.mode = scroll ? 'scroll' : 'manual';
    slider.hidden = scroll || motion.matches;
    meter.hidden = !scroll;
    steps.hidden = !motion.matches;
    mode.hidden = motion.matches;
    hint.hidden = !scroll;
    toggle.setAttribute('aria-pressed', String(scroll));
    toggle.textContent = scroll ? '切回滑杆' : '跟随页面滚动';

    if (scroll) {
      driver = connectScroll(measureTrack, applyProgress);
      resize.observe(panel);
      resize.observe(root);
      resize.observe(document.body);
    } else {
      const increments = motion.matches ? 2 : 100;
      applyProgress(Math.round(progress * increments) / increments);
      if (start !== undefined && window.scrollY > start) {
        window.scrollTo({ top: Math.max(0, start), behavior: 'instant' });
      }
    }
  }

  function syncMotion() {
    const hadFocus = controls.contains(document.activeElement);
    setMode(false, hadFocus);
    if (hadFocus) {
      const control = motion.matches ? steps.querySelector('[aria-pressed="true"]') : slider;
      control?.focus({ preventScroll: true });
    }
  }

  slider.addEventListener('input', () => {
    if (!driver && !motion.matches) applyProgress(slider.valueAsNumber / 100);
  });
  for (const button of stepButtons) {
    button.addEventListener('click', () => applyProgress(Number(button.dataset.progressStep) / 100));
  }
  toggle.addEventListener('click', () => setMode(!driver, true));
  root.addEventListener('formtempo:guide-reset', () => setMode(false));
  window.addEventListener('resize', refresh);
  window.addEventListener('pageshow', refresh);
  motion.addEventListener('change', syncMotion);
  controls.hidden = false;
  syncMotion();
}

  /** @param {HTMLElement} root */
function createSoftMaskRenderer(root) {
  const stage = root.querySelector('[data-soft-mask-reveal]');

  return (progress) => {
    stage.style.setProperty('--soft-mask-progress', String(progress));
    const state = progress === 0 ? 'empty' : progress === 1 ? 'full' : 'partial';
    if (stage.dataset.maskState !== state) stage.dataset.maskState = state;
  };
}

  const root = document.querySelector('[data-scroll-progress]');
  const render = createSoftMaskRenderer(root);
  if (window.gsap && window.ScrollTrigger) {
    gsap.registerPlugin(ScrollTrigger);
    bindScrollProgress(root, render, (measure, update) => {
      const trigger = ScrollTrigger.create({
        start: () => measure().start,
        end: () => measure().end,
        onUpdate: (self) => update(self.progress),
        onRefresh: (self) => update(self.progress),
      });
      update(trigger.progress);
      return { refresh: () => trigger.refresh(), destroy: () => trigger.kill() };
    });
  } else {
    document.querySelector('[data-gsap-fallback]').hidden = false;
    bindScrollProgress(root, render);
  }
})();
</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-soft-mask-reveal]","dock":"[data-progress-panel]","reset":"[data-scroll-progress]","steps":[{"text":"从关闭的可见区域开始。","target":"input[type=\"range\"]","action":"progress","value":0,"duration":0,"hold":360},{"text":"同样的进度下,比较清晰的裁剪线和渐隐的软边。","target":"input[type=\"range\"]","action":"progress","value":50,"duration":600,"hold":600},{"text":"展开到终点,两边都显示完整画面。","target":"input[type=\"range\"]","action":"progress","value":100,"duration":600,"hold":360},{"text":"回到一半,再看边缘和四角的差别。","target":"input[type=\"range\"]","action":"progress","value":50,"duration":600,"hold":360}]});
})();
</script>
</body>
</html>