mcpField
R
reveal-hover-effect

reveal-hover-effect

github.com/mengto设计与 UI7.1k1.4k 次安装3 天前更新
代码仓库

关于 reveal-hover-effect

用于实现指针驱动图像揭示效果的设计技能,通过柔和边缘的聚光遮罩显示叠层图像。内容涵盖动效、无障碍、响应式行为及组件清理。

主要功能

  1. 01对齐叠放基础图与揭示图
  2. 02柔和边缘的 CSS 径向遮罩
  3. 03使用 requestAnimationFrame 平滑跟踪指针
  4. 04触控与减少动态效果的回退方案
  5. 05可选网格视差与嵌套卡片

适用场景

  1. 01随指针移动展示替代图像
  2. 02为组件添加局部聚光效果
  3. 03让交互不依赖悬停也可使用
  4. 04效果空闲时停止动画计算

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/mengto/skills --skill reveal-hover-effect

安装后在 Claude Code 中输入/reveal-hover-effect确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

REFERENCES.md
agents/
openai.yaml
demo/
index.html
PROMPT.md

详细介绍

揭示悬停效果

核心约定

  1. 准备两张尺寸、构图、裁剪和焦点完全相同的图片。
  2. 完整显示基础图片。
  3. 将揭示图片直接叠放在基础图片上方。
  4. 为揭示图片应用带羽化效果的径向 mask-image。
  5. 在组件的本地坐标系中跟踪指针坐标。
  6. 使用 requestAnimationFrame 让渲染位置逐渐接近指针的实时位置。
  7. 指针移出时收起蒙版;不要让聚光效果残留。

默认使用 CSS 蒙版,而不是每帧生成 canvas data URL。CSS 方案能以更少的内存分配和更简单的清理方式实现相同效果。

动效默认值

  • 桌面端聚光半径:260px。
  • 紧凑布局聚光半径:140px 到 220px。
  • 指针缓动系数:0.1。
  • 半径缓动系数:0.14。
  • 蒙版渐变节点:
    • 0%:不透明度 1
    • 40%:不透明度 1
    • 60%:不透明度 0.75
    • 75%:不透明度 0.4
    • 88%:不透明度 0.12
    • 100%:不透明度 0
  • 初始状态:仅显示基础图片。
  • 退出状态:半径逐渐收缩至 0。
  • 光标:保留原生光标,除非设计明确要求使用自定义光标。

标记

使用真实图片,确保加载、固有尺寸和无障碍功能表现一致。

<figure class="reveal-hover" data-reveal-hover data-reveal-radius="260">
  <img
    class="reveal-hover__image reveal-hover__image--base"
    src="/images/product-linework.webp"
    alt="Sculpted product shown in a pale linework treatment"
    width="1600"
    height="1000"
    decoding="async"
  />
  <img
    class="reveal-hover__image reveal-hover__image--overlay"
    src="/images/product-color.webp"
    alt=""
    width="1600"
    height="1000"
    decoding="async"
    aria-hidden="true"
  />
</figure>

如果两张图片传达的是同一主题,请将叠加层设为装饰性内容。如果对比图包含独有信息,请提供可见标签或单独的无障碍描述。

CSS 蒙版

.reveal-hover {
  --reveal-x: 50%;
  --reveal-y: 50%;
  --reveal-radius: 0px;

  position: relative;
  overflow: clip;
  isolation: isolate;
  margin: 0;
  background: #f3f1ec;
  contain: paint;
}

.reveal-hover__image {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

.reveal-hover__image--base {
  position: relative;
  z-index: 0;
}

.reveal-hover__image--overlay {
  position: absolute;
  z-index: 1;
  inset: 0;
  pointer-events: none;
  -webkit-mask-image: radial-gradient(
    circle var(--reveal-radius) at var(--reveal-x) var(--reveal-y),
    rgb(0 0 0 / 1) 0%,
    rgb(0 0 0 / 1) 40%,
    rgb(0 0 0 / 0.75) 60%,
    rgb(0 0 0 / 0.4) 75%,
    rgb(0 0 0 / 0.12) 88%,
    transparent 100%
  );
  mask-image: radial-gradient(
    circle var(--reveal-radius) at var(--reveal-x) var(--reveal-y),
    rgb(0 0 0 / 1) 0%,
    rgb(0 0 0 / 1) 40%,
    rgb(0 0 0 / 0.75) 60%,
    rgb(0 0 0 / 0.4) 75%,
    rgb(0 0 0 / 0.12) 88%,
    transparent 100%
  );
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  will-change: -webkit-mask-image, mask-image;
}

@media (hover: none), (pointer: coarse) {
  .reveal-hover__image--overlay {
    display: none;
  }
}

两层图片的 object-fit 和 object-position 必须相同。即使只有 1 像素的偏差,在聚光区域内也会很明显。

指针缓动跟踪

仅在数值变化时运行动画循环。使用 getBoundingClientRect() 转换 clientX 和 clientY;滚动后,页面坐标会产生偏移。

function initRevealHover(element) {
  const overlay = element.querySelector(".reveal-hover__image--overlay");
  const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)");
  const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");

  if (!overlay || !finePointer.matches) return () => {};

  const state = {
    x: 0,
    y: 0,
    targetX: 0,
    targetY: 0,
    radius: 0,
    targetRadius: 0,
    clientX: 0,
    clientY: 0,
    inside: false,
    frame: 0,
  };

  const getRadius = () => {
    const requested = Number.parseFloat(element.dataset.revealRadius);
    if (Number.isFinite(requested)) return requested;
    return Math.min(260, Math.max(140, element.clientWidth * 0.22));
  };

  const updateTarget = (clientX, clientY) => {
    const rect = element.getBoundingClientRect();
    state.clientX = clientX;
    state.clientY = clientY;
    state.targetX = clientX - rect.left;
    state.targetY = clientY - rect.top;
  };

  const schedule = () => {
    if (!state.frame) state.frame = requestAnimationFrame(tick);
  };

  const tick = () => {
    state.frame = 0;

    const positionEase = reduceMotion.matches ? 1 : 0.1;
    const radiusEase = reduceMotion.matches ? 1 : 0.14;

    state.x += (state.targetX - state.x) * positionEase;
    state.y += (state.targetY - state.y) * positionEase;
    state.radius += (state.targetRadius - state.radius) * radiusEase;

    element.style.setProperty("--reveal-x", `${state.x.toFixed(2)}px`);
    element.style.setProperty("--reveal-y", `${state.y.toFixed(2)}px`);
    element.style.setProperty("--reveal-radius", `${state.radius.toFixed(2)}px`);

    const unsettled =
      Math.abs(state.targetX - state.x) > 0.1 ||
      Math.abs(state.targetY - state.y) > 0.1 ||
      Math.abs(state.targetRadius - state.radius) > 0.1;

    if (unsettled) schedule();
  };

  const onPointerEnter = (event) => {
    state.inside = true;
    updateTarget(event.clientX, event.clientY);

    if (state.radius < 0.5) {
      state.x = state.targetX;
      state.y = state.targetY;
    }

    state.targetRadius = getRadius();
    schedule();
  };

  const onPointerMove = (event) => {
    updateTarget(event.clientX, event.clientY);

    // A page can load with the pointer already over this element, so the
    // first pointermove may arrive without a preceding pointerenter.
    if (!state.inside) {
      state.inside = true;

      if (state.radius < 0.5) {
        state.x = state.targetX;
        state.y = state.targetY;
      }

      state.targetRadius = getRadius();
    }

    schedule();
  };

  const hideReveal = () => {
    state.inside = false;
    state.targetRadius = 0;
    schedule();
  };

  const onViewportChange = () => {
    if (!state.inside) return;
    updateTarget(state.clientX, state.clientY);
    state.targetRadius = getRadius();
    schedule();
  };

  element.addEventListener("pointerenter", onPointerEnter);
  element.addEventListener("pointermove", onPointerMove);
  element.addEventListener("pointerleave", hideReveal);
  element.addEventListener("pointercancel", hideReveal);
  window.addEventListener("blur", hideReveal);
  window.addEventListener("scroll", onViewportChange, { passive: true });

  const resizeObserver = new ResizeObserver(onViewportChange);
  resizeObserver.observe(element);

  return () => {
    if (state.frame) cancelAnimationFrame(state.frame);
    resizeObserver.disconnect();
    element.removeEventListener("pointerenter", onPointerEnter);
    element.removeEventListener("pointermove", onPointerMove);
    element.removeEventListener("pointerleave", hideReveal);
    element.removeEventListener("pointercancel", hideReveal);
    window.removeEventListener("blur", hideReveal);
    window.removeEventListener("scroll", onViewportChange);
  };
}

const revealHoverCleanups = Array.from(
  document.querySelectorAll("[data-reveal-hover]")
).map(initRevealHover);

在 React、Vue 或 Svelte 中,请在挂载后初始化,并在卸载时调用所有返回的清理函数。不要在每次渲染时都创建新的动画循环。

可选网格视差

仅在网格有助于呈现艺术方向时,才添加微妙的网格效果。

  • 使用 48px 的 SVG 或 CSS 网格。
  • 将不透明度保持在 0.1 左右。
  • 根据组件中心对缓动后的指针坐标进行归一化。
  • 将偏移限制在 ±16px。
  • 使用 0.06 的系数缓动网格偏移,使其速度慢于揭示效果。
  • 在 prefers-reduced-motion 下禁用偏移。

网格只是氛围效果,不是交互焦点。它的移动应当微乎其微。

嵌套玻璃或折射卡片

当前景卡片需要显示相同的替代效果时:

  1. 在卡片内叠放相同的基础资源和揭示资源。
  2. 使用相同的视口指针状态。
  3. 设置卡片的本地蒙版坐标前,先减去卡片 getBoundingClientRect().left 和 .top。
  4. 保留卡片自身的裁剪方式和圆角。
  5. 在同一个动画帧中更新卡片和主视觉,避免折射效果出现延迟。

触控与无障碍

  • 对粗指针设备,默认显示静态基础图片。
  • 如果揭示内容包含重要信息,请为触控和键盘用户添加明确的“显示替代内容”控件。
  • 不要依赖悬停来显示导航、价格、说明或重要文案。
  • 在减少动态效果设置下,直接更新聚光效果,不使用拖尾缓动,并禁用视差。
  • 保留原生光标,使揭示中心清晰可见。

性能规则

  • 使用单个 requestAnimationFrame 循环为 CSS 自定义属性添加动画。
  • 指针和半径稳定后停止循环。
  • 不要每帧调用 canvas.toDataURL()。
  • 确保两张图片的编码尺寸和响应式资源集相同。
  • 仅在带蒙版的叠加层上使用 will-change。
  • 在 Safari 中测试 mask-* 和 -webkit-mask-*。
  • 如果效果只占据单个组件,不要为整个页面大小的图层应用蒙版。

快速检查

  • 基础图和显现图在每个断点都保持像素级对齐。
  • 显现效果从指针下方开始,而不是从组件中心向外扫过。
  • 页面在光标静止时加载后,指针首次移动也能正确显现。
  • 全强度核心区域延伸至半径的 40%。
  • 羽化效果在边缘达到完全透明,不出现可见的环。
  • 指针移出和窗口失焦时,遮罩会收拢。
  • 鼠标悬停时滚动页面,聚光灯仍与光标保持同步。
  • 触屏用户有明确的静态或切换式备用方案。
  • 减少动态效果模式下,没有拖尾移动或网格漂移。
  • 空闲时动画循环会停止,并在路由变化时清理。

评论

支持 Markdown