关于 reveal-hover-effect
用于实现指针驱动图像揭示效果的设计技能,通过柔和边缘的聚光遮罩显示叠层图像。内容涵盖动效、无障碍、响应式行为及组件清理。
主要功能
- 01对齐叠放基础图与揭示图
- 02柔和边缘的 CSS 径向遮罩
- 03使用 requestAnimationFrame 平滑跟踪指针
- 04触控与减少动态效果的回退方案
- 05可选网格视差与嵌套卡片
适用场景
- 01随指针移动展示替代图像
- 02为组件添加局部聚光效果
- 03让交互不依赖悬停也可使用
- 04效果空闲时停止动画计算
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ mengto/ skills --skill reveal-hover-effect安装后在 Claude Code 中输入/reveal-hover-effect确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
REFERENCES.mdagents/openai.yamldemo/index.htmlPROMPT.md详细介绍
揭示悬停效果
核心约定
- 准备两张尺寸、构图、裁剪和焦点完全相同的图片。
- 完整显示基础图片。
- 将揭示图片直接叠放在基础图片上方。
- 为揭示图片应用带羽化效果的径向
mask-image。 - 在组件的本地坐标系中跟踪指针坐标。
- 使用
requestAnimationFrame让渲染位置逐渐接近指针的实时位置。 - 指针移出时收起蒙版;不要让聚光效果残留。
默认使用 CSS 蒙版,而不是每帧生成 canvas data URL。CSS 方案能以更少的内存分配和更简单的清理方式实现相同效果。
动效默认值
- 桌面端聚光半径:
260px。 - 紧凑布局聚光半径:
140px到220px。 - 指针缓动系数:
0.1。 - 半径缓动系数:
0.14。 - 蒙版渐变节点:
0%:不透明度140%:不透明度160%:不透明度0.7575%:不透明度0.488%:不透明度0.12100%:不透明度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下禁用偏移。
网格只是氛围效果,不是交互焦点。它的移动应当微乎其微。
嵌套玻璃或折射卡片
当前景卡片需要显示相同的替代效果时:
- 在卡片内叠放相同的基础资源和揭示资源。
- 使用相同的视口指针状态。
- 设置卡片的本地蒙版坐标前,先减去卡片
getBoundingClientRect().left和.top。 - 保留卡片自身的裁剪方式和圆角。
- 在同一个动画帧中更新卡片和主视觉,避免折射效果出现延迟。
触控与无障碍
- 对粗指针设备,默认显示静态基础图片。
- 如果揭示内容包含重要信息,请为触控和键盘用户添加明确的“显示替代内容”控件。
- 不要依赖悬停来显示导航、价格、说明或重要文案。
- 在减少动态效果设置下,直接更新聚光效果,不使用拖尾缓动,并禁用视差。
- 保留原生光标,使揭示中心清晰可见。
性能规则
- 使用单个
requestAnimationFrame循环为 CSS 自定义属性添加动画。 - 指针和半径稳定后停止循环。
- 不要每帧调用
canvas.toDataURL()。 - 确保两张图片的编码尺寸和响应式资源集相同。
- 仅在带蒙版的叠加层上使用
will-change。 - 在 Safari 中测试
mask-*和-webkit-mask-*。 - 如果效果只占据单个组件,不要为整个页面大小的图层应用蒙版。
快速检查
- 基础图和显现图在每个断点都保持像素级对齐。
- 显现效果从指针下方开始,而不是从组件中心向外扫过。
- 页面在光标静止时加载后,指针首次移动也能正确显现。
- 全强度核心区域延伸至半径的
40%。 - 羽化效果在边缘达到完全透明,不出现可见的环。
- 指针移出和窗口失焦时,遮罩会收拢。
- 鼠标悬停时滚动页面,聚光灯仍与光标保持同步。
- 触屏用户有明确的静态或切换式备用方案。
- 减少动态效果模式下,没有拖尾移动或网格漂移。
- 空闲时动画循环会停止,并在路由变化时清理。
评论
支持 Markdown