RGBずれグリッチ

ホバー/マウス移動で R・G・B チャンネルが左右にずれるアナグリフ風グリッチ。mix-blend-mode:screen で重ねた3レイヤーをマウス速度に応じて分離し、サイバー/音楽系のアクセントに最適です。

#image#glitch#rgb#hover

ライブデモ

使用例(お題: SaaS FlowDesk)

この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- FlowDesk:新機能ローンチ告知(RGBずれグリッチ) -->
<!-- 各チャンネル抽出用の SVG カラーマトリクス(画面には出ない) -->
<svg class="fd-rgb__defs" width="0" height="0" aria-hidden="true" focusable="false">
  <defs>
    <filter id="fdOnlyR" color-interpolation-filters="sRGB">
      <feColorMatrix type="matrix" values="1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0"/>
    </filter>
    <filter id="fdOnlyG" color-interpolation-filters="sRGB">
      <feColorMatrix type="matrix" values="0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0"/>
    </filter>
    <filter id="fdOnlyB" color-interpolation-filters="sRGB">
      <feColorMatrix type="matrix" values="0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0"/>
    </filter>
  </defs>
</svg>

<section class="fd-rgb-wrap">
  <!-- 製品画像を3枚重ねて RGB をずらす -->
  <figure class="fd-rgb" tabindex="0" aria-label="RGBずれグリッチ画像">
    <img class="fd-rgb__layer fd-rgb__layer--r" src="https://picsum.photos/seed/flowdesk-launch/720/520" alt="" aria-hidden="true">
    <img class="fd-rgb__layer fd-rgb__layer--g" src="https://picsum.photos/seed/flowdesk-launch/720/520" alt="" aria-hidden="true">
    <img class="fd-rgb__layer fd-rgb__layer--b" src="https://picsum.photos/seed/flowdesk-launch/720/520" alt="新機能のプレビュー">
    <figcaption class="fd-rgb__cap">v2.0</figcaption>
  </figure>

  <!-- 告知テキスト -->
  <div class="fd-rgb__info">
    <span class="fd-rgb__tag">MAJOR UPDATE</span>
    <h2 class="fd-rgb__title">FlowDesk 2.0、<br>始動。</h2>
    <p class="fd-rgb__lead">AIワークフローと、刷新したダッシュボード。チームのスピードを次の次元へ。</p>
    <a class="fd-rgb__btn" href="#">新機能を見る →</a>
  </div>
</section>
CSS
/* FlowDesk:新機能ローンチ告知(RGBずれグリッチ) */
:root {
  --navy: #0f1b34;
  --blue: #4f7cff;
  --shift: 0px;   /* JSが渡すずれ量(px) */
  --skew: 0deg;   /* 速度に応じた歪み */
  --scan: 0;      /* 走査線の濃さ */
}

* { box-sizing: border-box; }

body {
  margin: 0;
  height: 400px;
  display: flex;
  align-items: center;
  gap: 30px;
  padding: 0 32px;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  background: radial-gradient(120% 120% at 100% 0%, #1a2a52 0%, var(--navy) 70%);
  color: #fff;
  overflow: hidden;
}

/* SVGフィルタ定義はレイアウトに影響させない */
.fd-rgb__defs { position: absolute; width: 0; height: 0; pointer-events: none; }

/* グリッチ枠 */
.fd-rgb {
  position: relative;
  flex: 0 0 360px;
  height: 300px;
  margin: 0;
  border-radius: 14px;
  overflow: hidden;
  cursor: crosshair;
  outline: none;
  background: #000;
  box-shadow: 0 18px 45px rgba(0,0,0,0.55);
}
.fd-rgb:focus-visible { box-shadow: 0 0 0 3px var(--blue), 0 18px 45px rgba(0,0,0,0.55); }

/* 3レイヤーを screen 合成(重なれば元の色) */
.fd-rgb__layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  mix-blend-mode: screen;
  will-change: transform;
}
.fd-rgb__layer--r { filter: url(#fdOnlyR); }
.fd-rgb__layer--g { filter: url(#fdOnlyG); }
.fd-rgb__layer--b { filter: url(#fdOnlyB); }

/* R は左、B は右へ。G は基準 */
.fd-rgb__layer--r { transform: translateX(calc(var(--shift) * -1)) skewX(var(--skew)); }
.fd-rgb__layer--g { transform: translateX(0); }
.fd-rgb__layer--b { transform: translateX(var(--shift)) skewX(calc(var(--skew) * -1)); }

/* 走査線 */
.fd-rgb::after {
  content: "";
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(
    to bottom,
    rgba(0,0,0,0) 0,
    rgba(0,0,0,0.14) 2px,
    rgba(0,0,0,0) 3px
  );
  pointer-events: none;
  opacity: var(--scan, 0);
  transition: opacity 0.25s ease;
}

.fd-rgb__cap {
  position: absolute;
  left: 14px;
  bottom: 12px;
  z-index: 3;
  font-size: 16px;
  font-weight: 800;
  letter-spacing: 0.2em;
  color: #fff;
  text-shadow: 2px 0 #ff003c, -2px 0 #00e5ff;
  pointer-events: none;
}

/* 告知テキスト */
.fd-rgb__info { flex: 1; }
.fd-rgb__tag { font-size: 10px; letter-spacing: 0.3em; color: #8aa6ff; }
.fd-rgb__title {
  margin: 10px 0 12px;
  font-size: 27px;
  line-height: 1.35;
  font-weight: 800;
}
.fd-rgb__lead {
  margin: 0 0 22px;
  font-size: 13px;
  line-height: 1.85;
  max-width: 280px;
  color: rgba(255,255,255,0.75);
}
.fd-rgb__btn {
  display: inline-block;
  padding: 11px 22px;
  border-radius: 10px;
  background: var(--blue);
  color: #fff;
  font-size: 13px;
  font-weight: 700;
  text-decoration: none;
  box-shadow: 0 10px 24px rgba(79,124,255,0.45);
  transition: transform 0.2s ease;
}
.fd-rgb__btn:hover { transform: translateY(-2px); }

@media (prefers-reduced-motion: reduce) {
  .fd-rgb__layer { transition: none; }
  .fd-rgb__btn { transition: none; }
}
JavaScript
// マウスの移動量(速度)に応じて R/G/B のずれ量を CSS 変数で増減する
(() => {
  const rgb = document.querySelector(".fd-rgb");
  if (!rgb) return;

  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
  const MAX = 14;        // 最大ずれ量(px)
  const HOVER_BASE = 4;  // ホバー中の最低ずれ量(px)

  let shift = 0;
  let target = 0;
  let lastX = 0, lastY = 0, hasLast = false;
  let active = false;
  let raf = 0;

  // 速度→ずれ量
  const onMove = (e) => {
    active = true;
    if (hasLast) {
      const dx = e.clientX - lastX;
      const dy = e.clientY - lastY;
      const speed = Math.min(Math.hypot(dx, dy), 60);
      target = Math.min(HOVER_BASE + speed * 0.45, MAX);
    } else {
      target = HOVER_BASE;
    }
    lastX = e.clientX;
    lastY = e.clientY;
    hasLast = true;
    start();
  };

  const onEnter = () => { active = true; target = HOVER_BASE; start(); };
  const onLeave = () => { active = false; target = 0; hasLast = false; };
  const onFocus = () => { active = true; target = HOVER_BASE + 4; start(); };
  const onBlur = () => { active = false; target = 0; };

  rgb.addEventListener("pointermove", onMove);
  rgb.addEventListener("pointerenter", onEnter);
  rgb.addEventListener("pointerleave", onLeave);
  rgb.addEventListener("focus", onFocus);
  rgb.addEventListener("blur", onBlur);

  const tick = () => {
    if (active && target > HOVER_BASE) target += (HOVER_BASE - target) * 0.06;
    shift += (target - shift) * 0.2;

    const skew = (shift / MAX) * 2.5;
    rgb.style.setProperty("--shift", `${shift.toFixed(2)}px`);
    rgb.style.setProperty("--skew", `${skew.toFixed(2)}deg`);
    rgb.style.setProperty("--scan", `${Math.min(shift / 6, 1).toFixed(2)}`);

    if (!active && shift < 0.05) {
      shift = 0;
      rgb.style.setProperty("--shift", "0px");
      rgb.style.setProperty("--skew", "0deg");
      rgb.style.setProperty("--scan", "0");
      raf = 0;
      return;
    }
    raf = requestAnimationFrame(tick);
  };

  const start = () => { if (!raf && !reduce) raf = requestAnimationFrame(tick); };

  // reduce 指定時はホバーで固定ずれのみ
  if (reduce) {
    rgb.addEventListener("pointerenter", () => {
      rgb.style.setProperty("--shift", `${HOVER_BASE}px`);
      rgb.style.setProperty("--scan", "0.5");
    });
    rgb.addEventListener("pointerleave", () => {
      rgb.style.setProperty("--shift", "0px");
      rgb.style.setProperty("--scan", "0");
    });
  }
})();

コード

HTML
<!-- ホバー/マウス移動で R/G/B チャンネルが左右にずれるアナグリフ風グリッチ -->
<!-- 各チャンネル抽出用の SVG カラーマトリクス(画面には出ない) -->
<svg class="rgb__defs" width="0" height="0" aria-hidden="true" focusable="false">
  <defs>
    <!-- R のみ残す -->
    <filter id="only-r" color-interpolation-filters="sRGB">
      <feColorMatrix type="matrix" values="1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0"/>
    </filter>
    <!-- G のみ残す -->
    <filter id="only-g" color-interpolation-filters="sRGB">
      <feColorMatrix type="matrix" values="0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0"/>
    </filter>
    <!-- B のみ残す -->
    <filter id="only-b" color-interpolation-filters="sRGB">
      <feColorMatrix type="matrix" values="0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0"/>
    </filter>
  </defs>
</svg>
<div class="stage">
  <figure class="rgb" tabindex="0" aria-label="RGBずれグリッチ画像">
    <!-- 同一画像を3枚重ね、CSSで R/G/B に着色し transform でずらす -->
    <img class="rgb__layer rgb__layer--r" src="https://picsum.photos/seed/rgbshift/800/600" alt="" aria-hidden="true">
    <img class="rgb__layer rgb__layer--g" src="https://picsum.photos/seed/rgbshift/800/600" alt="" aria-hidden="true">
    <img class="rgb__layer rgb__layer--b" src="https://picsum.photos/seed/rgbshift/800/600" alt="RGBずれグリッチ対象画像">
    <figcaption class="rgb__cap">R G B</figcaption>
  </figure>
</div>
CSS
:root {
  --shift: 0px;          /* JSが渡すずれ量(px) */
  --skew: 0deg;          /* 速度に応じた歪み */
  --radius: 12px;
}
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  background:
    radial-gradient(120% 120% at 50% 10%, #15131f 0%, #07060b 70%);
  font-family: "Courier New", ui-monospace, monospace;
}
.stage { padding: 24px; }
/* SVGフィルタ定義はレイアウトに影響させない */
.rgb__defs { position: absolute; width: 0; height: 0; pointer-events: none; }

.rgb {
  position: relative;
  width: min(68vw, 400px);
  aspect-ratio: 4 / 3;
  margin: 0;
  border-radius: var(--radius);
  overflow: hidden;
  cursor: crosshair;
  outline: none;
  background: #000;
  box-shadow: 0 20px 50px -18px rgba(0, 0, 0, .9);
}
.rgb:focus-visible { box-shadow: 0 0 0 3px #00e5ff, 0 20px 50px -18px rgba(0, 0, 0, .9); }

/* 3レイヤーを重ね、screen 合成で加法混色(重なれば元の色に戻る) */
.rgb__layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  mix-blend-mode: screen;
  will-change: transform;
}
/* 各チャンネルだけ残す(他2色を落とす)色行列フィルタ */
.rgb__layer--r { filter: url(#only-r); }
.rgb__layer--g { filter: url(#only-g); }
.rgb__layer--b { filter: url(#only-b); }

/* R は左、B は右へ。G は基準。--shift をJSで増減 */
.rgb__layer--r { transform: translateX(calc(var(--shift) * -1)) skewX(var(--skew)); }
.rgb__layer--g { transform: translateX(0); }
.rgb__layer--b { transform: translateX(var(--shift)) skewX(calc(var(--skew) * -1)); }

/* 走査線。ずれが大きいほど濃く(--scan をJSで) */
.rgb::after {
  content: "";
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0) 0,
    rgba(0, 0, 0, .14) 2px,
    rgba(0, 0, 0, 0) 3px
  );
  pointer-events: none;
  opacity: var(--scan, 0);
  transition: opacity .25s ease;
}

.rgb__cap {
  position: absolute;
  left: 14px;
  bottom: 12px;
  z-index: 3;
  font-size: 16px;
  font-weight: 700;
  letter-spacing: .5em;
  color: #fff;
  text-shadow: 2px 0 #ff003c, -2px 0 #00e5ff;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .rgb__layer { transition: none; }
}
JavaScript
// マウスの移動量(速度)に応じて R/G/B のずれ量を CSS 変数で増減する
(() => {
  const rgb = document.querySelector(".rgb");
  if (!rgb) return;

  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
  const MAX = 14;        // 最大ずれ量(px)
  const HOVER_BASE = 4;  // ホバー中の最低ずれ量(px)

  let shift = 0;         // 現在のずれ量
  let target = 0;        // 目標ずれ量
  let lastX = 0, lastY = 0, hasLast = false;
  let active = false;
  let raf = 0;

  // 速度→ずれ量。動かすほど大きく、止めれば HOVER_BASE へ戻る
  const onMove = (e) => {
    active = true;
    if (hasLast) {
      const dx = e.clientX - lastX;
      const dy = e.clientY - lastY;
      const speed = Math.min(Math.hypot(dx, dy), 60);
      target = Math.min(HOVER_BASE + speed * 0.45, MAX);
    } else {
      target = HOVER_BASE;
    }
    lastX = e.clientX;
    lastY = e.clientY;
    hasLast = true;
    start();
  };

  const onEnter = () => { active = true; target = HOVER_BASE; start(); };
  const onLeave = () => { active = false; target = 0; hasLast = false; };
  const onFocus = () => { active = true; target = HOVER_BASE + 4; start(); };
  const onBlur = () => { active = false; target = 0; };

  rgb.addEventListener("pointermove", onMove);
  rgb.addEventListener("pointerenter", onEnter);
  rgb.addEventListener("pointerleave", onLeave);
  rgb.addEventListener("focus", onFocus);
  rgb.addEventListener("blur", onBlur);

  // ホバー中はじわじわ減衰(止めても完全には消えず微振動)
  const tick = () => {
    // ホバー継続中は target を少しずつ HOVER_BASE に戻す
    if (active && target > HOVER_BASE) target += (HOVER_BASE - target) * 0.06;
    shift += (target - shift) * 0.2;

    const skew = (shift / MAX) * 2.5; // ずれに比例した skew(deg)
    rgb.style.setProperty("--shift", `${shift.toFixed(2)}px`);
    rgb.style.setProperty("--skew", `${skew.toFixed(2)}deg`);
    rgb.style.setProperty("--scan", `${Math.min(shift / 6, 1).toFixed(2)}`);

    // ほぼ静止したらループ停止(非アクティブ時のみ)
    if (!active && shift < 0.05) {
      shift = 0;
      rgb.style.setProperty("--shift", "0px");
      rgb.style.setProperty("--skew", "0deg");
      rgb.style.setProperty("--scan", "0");
      raf = 0;
      return;
    }
    raf = requestAnimationFrame(tick);
  };

  const start = () => { if (!raf && !reduce) raf = requestAnimationFrame(tick); };

  // reduce 指定時はホバーで固定ずれのみ(アニメ無し)
  if (reduce) {
    rgb.addEventListener("pointerenter", () => {
      rgb.style.setProperty("--shift", `${HOVER_BASE}px`);
      rgb.style.setProperty("--scan", "0.5");
    });
    rgb.addEventListener("pointerleave", () => {
      rgb.style.setProperty("--shift", "0px");
      rgb.style.setProperty("--scan", "0");
    });
  }
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「RGBずれグリッチ」の効果を追加してください。

# 追加してほしい効果
RGBずれグリッチ(画像エフェクト)
ホバー/マウス移動で R・G・B チャンネルが左右にずれるアナグリフ風グリッチ。mix-blend-mode:screen で重ねた3レイヤーをマウス速度に応じて分離し、サイバー/音楽系のアクセントに最適です。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- ホバー/マウス移動で R/G/B チャンネルが左右にずれるアナグリフ風グリッチ -->
<!-- 各チャンネル抽出用の SVG カラーマトリクス(画面には出ない) -->
<svg class="rgb__defs" width="0" height="0" aria-hidden="true" focusable="false">
  <defs>
    <!-- R のみ残す -->
    <filter id="only-r" color-interpolation-filters="sRGB">
      <feColorMatrix type="matrix" values="1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0"/>
    </filter>
    <!-- G のみ残す -->
    <filter id="only-g" color-interpolation-filters="sRGB">
      <feColorMatrix type="matrix" values="0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0"/>
    </filter>
    <!-- B のみ残す -->
    <filter id="only-b" color-interpolation-filters="sRGB">
      <feColorMatrix type="matrix" values="0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0"/>
    </filter>
  </defs>
</svg>
<div class="stage">
  <figure class="rgb" tabindex="0" aria-label="RGBずれグリッチ画像">
    <!-- 同一画像を3枚重ね、CSSで R/G/B に着色し transform でずらす -->
    <img class="rgb__layer rgb__layer--r" src="https://picsum.photos/seed/rgbshift/800/600" alt="" aria-hidden="true">
    <img class="rgb__layer rgb__layer--g" src="https://picsum.photos/seed/rgbshift/800/600" alt="" aria-hidden="true">
    <img class="rgb__layer rgb__layer--b" src="https://picsum.photos/seed/rgbshift/800/600" alt="RGBずれグリッチ対象画像">
    <figcaption class="rgb__cap">R G B</figcaption>
  </figure>
</div>

【CSS】
:root {
  --shift: 0px;          /* JSが渡すずれ量(px) */
  --skew: 0deg;          /* 速度に応じた歪み */
  --radius: 12px;
}
* { box-sizing: border-box; }
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  background:
    radial-gradient(120% 120% at 50% 10%, #15131f 0%, #07060b 70%);
  font-family: "Courier New", ui-monospace, monospace;
}
.stage { padding: 24px; }
/* SVGフィルタ定義はレイアウトに影響させない */
.rgb__defs { position: absolute; width: 0; height: 0; pointer-events: none; }

.rgb {
  position: relative;
  width: min(68vw, 400px);
  aspect-ratio: 4 / 3;
  margin: 0;
  border-radius: var(--radius);
  overflow: hidden;
  cursor: crosshair;
  outline: none;
  background: #000;
  box-shadow: 0 20px 50px -18px rgba(0, 0, 0, .9);
}
.rgb:focus-visible { box-shadow: 0 0 0 3px #00e5ff, 0 20px 50px -18px rgba(0, 0, 0, .9); }

/* 3レイヤーを重ね、screen 合成で加法混色(重なれば元の色に戻る) */
.rgb__layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  mix-blend-mode: screen;
  will-change: transform;
}
/* 各チャンネルだけ残す(他2色を落とす)色行列フィルタ */
.rgb__layer--r { filter: url(#only-r); }
.rgb__layer--g { filter: url(#only-g); }
.rgb__layer--b { filter: url(#only-b); }

/* R は左、B は右へ。G は基準。--shift をJSで増減 */
.rgb__layer--r { transform: translateX(calc(var(--shift) * -1)) skewX(var(--skew)); }
.rgb__layer--g { transform: translateX(0); }
.rgb__layer--b { transform: translateX(var(--shift)) skewX(calc(var(--skew) * -1)); }

/* 走査線。ずれが大きいほど濃く(--scan をJSで) */
.rgb::after {
  content: "";
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0) 0,
    rgba(0, 0, 0, .14) 2px,
    rgba(0, 0, 0, 0) 3px
  );
  pointer-events: none;
  opacity: var(--scan, 0);
  transition: opacity .25s ease;
}

.rgb__cap {
  position: absolute;
  left: 14px;
  bottom: 12px;
  z-index: 3;
  font-size: 16px;
  font-weight: 700;
  letter-spacing: .5em;
  color: #fff;
  text-shadow: 2px 0 #ff003c, -2px 0 #00e5ff;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .rgb__layer { transition: none; }
}

【JavaScript】
// マウスの移動量(速度)に応じて R/G/B のずれ量を CSS 変数で増減する
(() => {
  const rgb = document.querySelector(".rgb");
  if (!rgb) return;

  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
  const MAX = 14;        // 最大ずれ量(px)
  const HOVER_BASE = 4;  // ホバー中の最低ずれ量(px)

  let shift = 0;         // 現在のずれ量
  let target = 0;        // 目標ずれ量
  let lastX = 0, lastY = 0, hasLast = false;
  let active = false;
  let raf = 0;

  // 速度→ずれ量。動かすほど大きく、止めれば HOVER_BASE へ戻る
  const onMove = (e) => {
    active = true;
    if (hasLast) {
      const dx = e.clientX - lastX;
      const dy = e.clientY - lastY;
      const speed = Math.min(Math.hypot(dx, dy), 60);
      target = Math.min(HOVER_BASE + speed * 0.45, MAX);
    } else {
      target = HOVER_BASE;
    }
    lastX = e.clientX;
    lastY = e.clientY;
    hasLast = true;
    start();
  };

  const onEnter = () => { active = true; target = HOVER_BASE; start(); };
  const onLeave = () => { active = false; target = 0; hasLast = false; };
  const onFocus = () => { active = true; target = HOVER_BASE + 4; start(); };
  const onBlur = () => { active = false; target = 0; };

  rgb.addEventListener("pointermove", onMove);
  rgb.addEventListener("pointerenter", onEnter);
  rgb.addEventListener("pointerleave", onLeave);
  rgb.addEventListener("focus", onFocus);
  rgb.addEventListener("blur", onBlur);

  // ホバー中はじわじわ減衰(止めても完全には消えず微振動)
  const tick = () => {
    // ホバー継続中は target を少しずつ HOVER_BASE に戻す
    if (active && target > HOVER_BASE) target += (HOVER_BASE - target) * 0.06;
    shift += (target - shift) * 0.2;

    const skew = (shift / MAX) * 2.5; // ずれに比例した skew(deg)
    rgb.style.setProperty("--shift", `${shift.toFixed(2)}px`);
    rgb.style.setProperty("--skew", `${skew.toFixed(2)}deg`);
    rgb.style.setProperty("--scan", `${Math.min(shift / 6, 1).toFixed(2)}`);

    // ほぼ静止したらループ停止(非アクティブ時のみ)
    if (!active && shift < 0.05) {
      shift = 0;
      rgb.style.setProperty("--shift", "0px");
      rgb.style.setProperty("--skew", "0deg");
      rgb.style.setProperty("--scan", "0");
      raf = 0;
      return;
    }
    raf = requestAnimationFrame(tick);
  };

  const start = () => { if (!raf && !reduce) raf = requestAnimationFrame(tick); };

  // reduce 指定時はホバーで固定ずれのみ(アニメ無し)
  if (reduce) {
    rgb.addEventListener("pointerenter", () => {
      rgb.style.setProperty("--shift", `${HOVER_BASE}px`);
      rgb.style.setProperty("--scan", "0.5");
    });
    rgb.addEventListener("pointerleave", () => {
      rgb.style.setProperty("--shift", "0px");
      rgb.style.setProperty("--scan", "0");
    });
  }
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。