mcpField
R
reveal-hover-effect

reveal-hover-effect

github.com/mengtoデザイン・UI7.1k1.4k 回インストール3 日前に更新
リポジトリ

reveal-hover-effect について

ポインターに反応する画像表示エフェクトを実装するためのデザインスキルです。ぼかしたスポットライトマスクに加え、モーション、アクセシビリティ、レスポンシブ対応、後処理を扱います。

主な機能

  1. 01位置を合わせた画像の重ね合わせ
  2. 02境界をぼかした CSS 円形マスク
  3. 03requestAnimationFrame によるポインター追従
  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枚用意します。
  2. ベース画像全体を表示したままにします。
  3. 切り替え用画像をベース画像の真上に重ねます。
  4. 切り替え用画像に、境界をぼかした放射状の mask-image を適用します。
  5. ポインターの座標をコンポーネントのローカル座標系で追跡します。
  6. requestAnimationFrame を使って、描画位置を実際のポインター位置に向けて滑らかに変化させます。
  7. ポインターが外れたらマスクを閉じ、スポットライトが残らないようにします。

フレームごとにキャンバスのデータ URL を生成するのではなく、CSS マスクをデフォルトで使用します。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>

2枚の画像が同じ対象を伝える場合は、オーバーレイを装飾として扱います。比較によって独自の情報が伝わる場合は、見えるラベルか、別のアクセシブルな説明を用意します。

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. 屈折効果が遅れないよう、同じアニメーションフレームでカードとヒーローを更新します。

タッチ操作とアクセシビリティ

  • 粗いポインターは、デフォルトで静的なベース画像を表示します。
  • 切り替え画像に意味のある情報が含まれる場合は、タッチ操作とキーボード操作のユーザー向けに、明示的な「別の画像を表示」コントロールを追加します。
  • ナビゲーション、料金、説明、不可欠なテキストを表示するために、ホバーに依存しないでください。
  • モーション軽減が設定されている場合は、遅延を伴うイージングを使わずにスポットライトを直接更新し、視差効果を無効にします。
  • 切り替えの中心位置が分かるよう、ネイティブカーソルを表示したままにします。

パフォーマンスに関するルール

  • 1つの requestAnimationFrame ループから CSS カスタムプロパティをアニメーションさせます。
  • ポインターと半径が安定したら、ループを停止します。
  • フレームごとに canvas.toDataURL() を呼び出さないでください。
  • 2枚の画像で同じエンコード寸法とレスポンシブなソースセットを使用します。
  • will-change はマスクされたオーバーレイにのみ使用します。
  • mask-* と -webkit-mask-* の両方を使って Safari でテストします。
  • エフェクトが1つのコンポーネント内に収まる場合は、ページ全体サイズのレイヤーにマスクを適用しないでください。

クイックチェック

  • すべてのブレークポイントで、ベースとリビールのアセットがピクセル単位で位置合わせされています。
  • リビールはコンポーネントの中央から広がるのではなく、ポインターの下から始まります。
  • カーソルを動かさずにページを読み込んだ場合でも、最初のポインター移動で正しくリビールされます。
  • 半径の 40% までは、コアの強度が最大に保たれます。
  • フェザーは目に見える輪を残さず、端で完全に透明になります。
  • ポインターが領域から出たときとウィンドウがフォーカスを失ったときに、マスクが閉じます。
  • ホバー中にスクロールしても、スポットライトがカーソルからずれません。
  • タッチ操作では、意図的に設計された静的表示または切り替え式のフォールバックが用意されています。
  • 視差効果を減らすモードでは、動きの遅れやグリッドのずれが発生しません。
  • アイドル時にはアニメーションループが停止し、ルート変更時にはクリーンアップされます。

コメント