フラッシュライト追従

暗いヒーローをカーソル追従の radial-gradient マスクで懐中電灯のように照らし、隠しテキストや画像を浮かび上がらせる演出。離れた周辺は闇に沈みます。謎解きや段階的開示の表現に使えます。

#cursor#mask#spotlight

ライブデモ

使用例(お題: アイドルグループ Sakura)

この技法を「アイドルグループ Sakura」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- Sakura: 次のライブ告知ティザー。暗幕をフラッシュライトで照らして解禁する演出 -->
<div class="hero" data-flashlight-root>
  <!-- 隠しコンテンツ層: 光の当たった所だけ見える -->
  <div class="reveal">
    <img class="photo" src="https://picsum.photos/900/600?random=31&grayscale" alt="">
    <div class="copy">
      <p class="kicker">NEXT LIVE 解禁</p>
      <p class="title">夜桜<br>FANTASIA</p>
      <p class="lead">2026.05.16 SAT / さくらドーム<br>5人そろって、春の大舞台へ。</p>
    </div>
  </div>

  <!-- 常時うっすら見える案内 -->
  <p class="hint">🌸 ライトを当てて告知を照らし出そう</p>

  <!-- カーソル追従の柔らかな光の輪 -->
  <div class="glow" data-glow></div>
</div>
CSS
/* Sakura ティザー: 暗幕に桜ピンクの光で告知を浮かび上がらせる */
* { box-sizing: border-box; }
/* iframe全面を暗く塗る(一覧でも白抜けしない) */
html, body {
  margin: 0;
  width: 100%;
  min-height: 100%;
  background: #120a10;
}

.hero {
  position: relative;
  width: 100%;
  min-height: 360px;
  height: 100vh;
  max-height: 100%;
  overflow: hidden;
  background: #120a10;
  /* 光の中心座標(JSが更新) */
  --mx: 50%;
  --my: 50%;
  --r: 120px;
  font-family: "Hiragino Sans", "Yu Gothic", system-ui, sans-serif;
  cursor: none;
}

/* 隠しコンテンツ層: 円形マスクで光の当たった所だけ表示 */
.reveal {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  text-align: center;
  -webkit-mask: radial-gradient(
    circle var(--r) at var(--mx) var(--my),
    #000 0%, #000 45%, transparent 72%
  );
  mask: radial-gradient(
    circle var(--r) at var(--mx) var(--my),
    #000 0%, #000 45%, transparent 72%
  );
  transition: -webkit-mask-size .2s ease, mask-size .2s ease;
}

/* 背景写真: 暗めに沈め、光の中だけ色づく */
.photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: saturate(1.05) brightness(.86) sepia(.2) hue-rotate(-12deg);
}

/* 写真の上に重ねる隠しコピー */
.copy {
  position: relative;
  z-index: 1;
  padding: 24px;
  color: #fff;
  text-shadow: 0 2px 18px rgba(0,0,0,.7);
}
.copy .kicker {
  margin: 0 0 8px;
  font-size: 13px;
  font-weight: 800;
  letter-spacing: .24em;
  color: #ffd1e0;
}
.copy .title {
  margin: 0 0 12px;
  font-size: clamp(34px, 8vw, 56px);
  font-weight: 900;
  letter-spacing: .08em;
  line-height: 1.1;
}
.copy .lead { margin: 0; font-size: 14px; line-height: 1.7; }

/* 暗闇に浮かぶ薄い案内文 */
.hint {
  position: absolute;
  left: 0; right: 0; bottom: 18px;
  margin: 0;
  text-align: center;
  font-size: 12px;
  letter-spacing: .12em;
  color: rgba(255,209,224,.22);
  pointer-events: none;
}

/* カーソル追従の柔らかな桜色の光の輪 */
.glow {
  position: absolute;
  top: 0; left: 0;
  width: 240px; height: 240px;
  margin: -120px 0 0 -120px;
  border-radius: 50%;
  pointer-events: none;
  background: radial-gradient(circle, rgba(255,209,224,.4) 0%, rgba(255,209,224,0) 65%);
  mix-blend-mode: screen;
  opacity: 0;
  transform: translate3d(var(--mx), var(--my), 0);
  transition: opacity .25s ease;
}
.hero[data-active="true"] .glow { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .reveal { transition: none; }
  .glow { transition: none; }
}
JavaScript
// Sakura: フラッシュライトで告知を照らす。待機中は自動巡回、操作で本物にlerp追従
(() => {
  const root = document.querySelector('[data-flashlight-root]');
  const glow = document.querySelector('[data-glow]');
  if (!root) return; // null安全

  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  let tx = 0, ty = 0;     // 目標
  let cx = 0, cy = 0;     // 現在
  let usePointer = false;
  let lastMove = 0;
  const IDLE = 1600;      // 無操作で自動巡回へ戻る(ms)

  // CSS変数と光の輪へ反映
  const apply = () => {
    root.style.setProperty('--mx', `${cx}px`);
    root.style.setProperty('--my', `${cy}px`);
    if (glow) glow.style.transform = `translate3d(${cx}px, ${cy}px, 0)`;
  };

  // reduced-motion: 中央に静的配置してデモ内容を提示
  if (reduce) {
    const r = root.getBoundingClientRect();
    cx = tx = r.width / 2;
    cy = ty = r.height / 2;
    root.dataset.active = 'true';
    apply();
    root.addEventListener('pointermove', (e) => {
      const rr = root.getBoundingClientRect();
      cx = tx = Math.min(Math.max(e.clientX - rr.left, 0), rr.width);
      cy = ty = Math.min(Math.max(e.clientY - rr.top, 0), rr.height);
      apply();
    });
    return;
  }

  // 仮想カーソルの自動経路: 告知の上をゆっくり照らして巡回
  const autoTarget = (t) => {
    const r = root.getBoundingClientRect();
    const mx = r.width / 2, my = r.height / 2;
    const ax = r.width * 0.32, ay = r.height * 0.28;
    tx = mx + Math.sin(t * 0.00050) * ax;
    ty = my + Math.sin(t * 0.00080 + 0.8) * ay;
  };

  root.addEventListener('pointermove', (e) => {
    const r = root.getBoundingClientRect();
    tx = Math.min(Math.max(e.clientX - r.left, 0), r.width);
    ty = Math.min(Math.max(e.clientY - r.top, 0), r.height);
    usePointer = true;
    lastMove = performance.now();
    root.dataset.active = 'true';
  });

  // ホバーで光をやや広げる
  root.addEventListener('pointerenter', () => { root.style.setProperty('--r', '135px'); });
  root.addEventListener('pointerleave', () => { root.style.setProperty('--r', '120px'); });

  const loop = (now) => {
    if (usePointer && now - lastMove > IDLE) usePointer = false;
    if (!usePointer) {
      autoTarget(now);
      root.dataset.active = 'true';
    }
    cx += (tx - cx) * 0.18;
    cy += (ty - cy) * 0.18;
    apply();
    requestAnimationFrame(loop);
  };

  const r0 = root.getBoundingClientRect();
  cx = tx = r0.width / 2;
  cy = ty = r0.height / 2;
  root.dataset.active = 'true';
  apply();
  requestAnimationFrame(loop);
})();

コード

HTML
<!-- フラッシュライト追従:暗いヒーローをカーソル位置の光で照らし隠し要素を見せる -->
<div class="hero" data-flashlight-root>
  <!-- 隠しコンテンツ層(マスクで照らした所だけ見える) -->
  <div class="reveal" data-reveal>
    <img class="photo" src="https://picsum.photos/720/360?random=21" alt="隠し画像" />
    <div class="copy">
      <h1 class="title">FIND ME</h1>
      <p class="lead">光を当てた場所だけ、世界が浮かび上がる。</p>
    </div>
  </div>
  <!-- 周辺をうっすら見せる薄い案内(暗闇のヒント) -->
  <p class="hint" aria-hidden="true">カーソルを動かして探してみよう</p>
  <!-- 柔らかな光の輪(カーソル追従の演出用グロー) -->
  <span class="glow" data-glow aria-hidden="true"></span>
</div>
CSS
* { box-sizing: border-box; }
/* iframe全面を暗く塗る(一覧でも白抜けしない) */
html, body {
  margin: 0;
  width: 100%;
  min-height: 100%;
  background: #07070d;
}

.hero {
  position: relative;
  width: 100%;
  min-height: 360px;
  height: 100vh;
  max-height: 100%;
  overflow: hidden;
  background: #07070d;
  /* 光の中心座標をCSS変数で保持(JSが更新) */
  --mx: 50%;
  --my: 50%;
  /* 光の半径(ホバーやreduced-motionで切替) */
  --r: 120px;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  cursor: none;
}

/* 隠しコンテンツ層:円形マスクで光の当たった所だけ表示 */
.reveal {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  text-align: center;
  /* 中心は不透明→外側へ向けて透明。境界をぼかして懐中電灯らしく */
  -webkit-mask: radial-gradient(
    circle var(--r) at var(--mx) var(--my),
    #000 0%, #000 45%, transparent 72%
  );
  mask: radial-gradient(
    circle var(--r) at var(--mx) var(--my),
    #000 0%, #000 45%, transparent 72%
  );
  transition: -webkit-mask-size .2s ease, mask-size .2s ease;
}

/* 背景写真:暗めに沈め、光の中だけで色づく */
.photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: saturate(1.05) brightness(.92);
}

/* 写真の上に重ねる隠しコピー */
.copy {
  position: relative;
  z-index: 1;
  padding: 24px;
  color: #fff;
  text-shadow: 0 2px 18px rgba(0,0,0,.6);
}
.copy .title {
  margin: 0 0 10px;
  font-size: clamp(36px, 8vw, 60px);
  font-weight: 900;
  letter-spacing: .08em;
}
.copy .lead { margin: 0; font-size: 14px; }

/* 暗闇に浮かぶ薄い案内文(常時うっすら表示) */
.hint {
  position: absolute;
  left: 0; right: 0; bottom: 18px;
  margin: 0;
  text-align: center;
  font-size: 12px;
  letter-spacing: .12em;
  color: rgba(180,190,220,.16);
  pointer-events: none;
}

/* カーソル追従の柔らかい光の輪(screen合成でふわっと) */
.glow {
  position: absolute;
  top: 0; left: 0;
  width: 240px; height: 240px;
  margin: -120px 0 0 -120px;
  border-radius: 50%;
  pointer-events: none;
  background: radial-gradient(circle, rgba(255,244,214,.35) 0%, rgba(255,244,214,0) 65%);
  mix-blend-mode: screen;
  opacity: 0;
  transform: translate3d(var(--mx), var(--my), 0);
  transition: opacity .25s ease;
}
.hero[data-active="true"] .glow { opacity: 1; }

/* モーション控えめ:光の輪のトランジションを抑える(追従自体は機能継続) */
@media (prefers-reduced-motion: reduce) {
  .reveal { transition: none; }
  .glow { transition: none; }
}
JavaScript
// フラッシュライト追従:仮想カーソルで自動巡回しつつ、操作時は本物にlerp追従
(() => {
  const root = document.querySelector('[data-flashlight-root]');
  const glow = document.querySelector('[data-glow]');
  if (!root) return; // null安全

  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 目標座標と現在座標(lerpで滑らかに追従)
  let tx = 0, ty = 0;     // 目標
  let cx = 0, cy = 0;     // 現在
  let usePointer = false; // 本物のポインタ追従中か
  let lastMove = 0;       // 最後にポインタが動いた時刻
  let raf = 0;
  const IDLE = 1600;      // 無操作でこの時間が過ぎたら自動巡回へ戻る(ms)

  // CSS変数と光の輪へ反映
  const apply = () => {
    root.style.setProperty('--mx', `${cx}px`);
    root.style.setProperty('--my', `${cy}px`);
    if (glow) glow.style.transform = `translate3d(${cx}px, ${cy}px, 0)`;
  };

  // reduced-motion:中央に静的配置して「光で照らすデモ」だと分かる初期表示
  if (reduce) {
    const r = root.getBoundingClientRect();
    cx = tx = r.width / 2;
    cy = ty = r.height / 2;
    root.dataset.active = 'true'; // 光の輪を表示
    apply();
    // pointermoveがあれば即時追従(控えめ動作)
    root.addEventListener('pointermove', (e) => {
      const rr = root.getBoundingClientRect();
      cx = tx = Math.min(Math.max(e.clientX - rr.left, 0), rr.width);
      cy = ty = Math.min(Math.max(e.clientY - rr.top, 0), rr.height);
      apply();
    });
    return;
  }

  // 仮想カーソルの自動経路(リサージュ):ゆっくり画面を照らして巡回
  const autoTarget = (t) => {
    const r = root.getBoundingClientRect();
    const mx = r.width / 2, my = r.height / 2;
    const ax = r.width * 0.32, ay = r.height * 0.28;
    tx = mx + Math.sin(t * 0.00050) * ax;
    ty = my + Math.sin(t * 0.00080 + 0.8) * ay;
  };

  // pointermoveで目標座標を更新(本物に追従)
  root.addEventListener('pointermove', (e) => {
    const r = root.getBoundingClientRect();
    tx = Math.min(Math.max(e.clientX - r.left, 0), r.width);
    ty = Math.min(Math.max(e.clientY - r.top, 0), r.height);
    usePointer = true;
    lastMove = performance.now();
    root.dataset.active = 'true';
  });

  // ホバー時は光をやや広げる
  root.addEventListener('pointerenter', () => { root.style.setProperty('--r', '135px'); });

  // 領域外では光半径を戻す(自動巡回は継続)
  root.addEventListener('pointerleave', () => {
    root.style.setProperty('--r', '120px');
  });

  // メインループ:アイドル中は自動目標、操作後しばらくは本物。lerpで滑らかに寄せる
  const loop = (now) => {
    if (usePointer && now - lastMove > IDLE) usePointer = false; // 無操作で自動へ復帰
    if (!usePointer) {
      autoTarget(now);
      root.dataset.active = 'true'; // 自動巡回中も光の輪を表示
    }
    cx += (tx - cx) * 0.18;
    cy += (ty - cy) * 0.18;
    apply();
    raf = requestAnimationFrame(loop);
  };

  // 初期位置を中央にしてループ開始
  const r0 = root.getBoundingClientRect();
  cx = tx = r0.width / 2;
  cy = ty = r0.height / 2;
  root.dataset.active = 'true';
  apply();
  raf = requestAnimationFrame(loop);
})();

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

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

# 追加してほしい効果
フラッシュライト追従(カスタムカーソル)
暗いヒーローをカーソル追従の radial-gradient マスクで懐中電灯のように照らし、隠しテキストや画像を浮かび上がらせる演出。離れた周辺は闇に沈みます。謎解きや段階的開示の表現に使えます。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- フラッシュライト追従:暗いヒーローをカーソル位置の光で照らし隠し要素を見せる -->
<div class="hero" data-flashlight-root>
  <!-- 隠しコンテンツ層(マスクで照らした所だけ見える) -->
  <div class="reveal" data-reveal>
    <img class="photo" src="https://picsum.photos/720/360?random=21" alt="隠し画像" />
    <div class="copy">
      <h1 class="title">FIND ME</h1>
      <p class="lead">光を当てた場所だけ、世界が浮かび上がる。</p>
    </div>
  </div>
  <!-- 周辺をうっすら見せる薄い案内(暗闇のヒント) -->
  <p class="hint" aria-hidden="true">カーソルを動かして探してみよう</p>
  <!-- 柔らかな光の輪(カーソル追従の演出用グロー) -->
  <span class="glow" data-glow aria-hidden="true"></span>
</div>

【CSS】
* { box-sizing: border-box; }
/* iframe全面を暗く塗る(一覧でも白抜けしない) */
html, body {
  margin: 0;
  width: 100%;
  min-height: 100%;
  background: #07070d;
}

.hero {
  position: relative;
  width: 100%;
  min-height: 360px;
  height: 100vh;
  max-height: 100%;
  overflow: hidden;
  background: #07070d;
  /* 光の中心座標をCSS変数で保持(JSが更新) */
  --mx: 50%;
  --my: 50%;
  /* 光の半径(ホバーやreduced-motionで切替) */
  --r: 120px;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  cursor: none;
}

/* 隠しコンテンツ層:円形マスクで光の当たった所だけ表示 */
.reveal {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  text-align: center;
  /* 中心は不透明→外側へ向けて透明。境界をぼかして懐中電灯らしく */
  -webkit-mask: radial-gradient(
    circle var(--r) at var(--mx) var(--my),
    #000 0%, #000 45%, transparent 72%
  );
  mask: radial-gradient(
    circle var(--r) at var(--mx) var(--my),
    #000 0%, #000 45%, transparent 72%
  );
  transition: -webkit-mask-size .2s ease, mask-size .2s ease;
}

/* 背景写真:暗めに沈め、光の中だけで色づく */
.photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: saturate(1.05) brightness(.92);
}

/* 写真の上に重ねる隠しコピー */
.copy {
  position: relative;
  z-index: 1;
  padding: 24px;
  color: #fff;
  text-shadow: 0 2px 18px rgba(0,0,0,.6);
}
.copy .title {
  margin: 0 0 10px;
  font-size: clamp(36px, 8vw, 60px);
  font-weight: 900;
  letter-spacing: .08em;
}
.copy .lead { margin: 0; font-size: 14px; }

/* 暗闇に浮かぶ薄い案内文(常時うっすら表示) */
.hint {
  position: absolute;
  left: 0; right: 0; bottom: 18px;
  margin: 0;
  text-align: center;
  font-size: 12px;
  letter-spacing: .12em;
  color: rgba(180,190,220,.16);
  pointer-events: none;
}

/* カーソル追従の柔らかい光の輪(screen合成でふわっと) */
.glow {
  position: absolute;
  top: 0; left: 0;
  width: 240px; height: 240px;
  margin: -120px 0 0 -120px;
  border-radius: 50%;
  pointer-events: none;
  background: radial-gradient(circle, rgba(255,244,214,.35) 0%, rgba(255,244,214,0) 65%);
  mix-blend-mode: screen;
  opacity: 0;
  transform: translate3d(var(--mx), var(--my), 0);
  transition: opacity .25s ease;
}
.hero[data-active="true"] .glow { opacity: 1; }

/* モーション控えめ:光の輪のトランジションを抑える(追従自体は機能継続) */
@media (prefers-reduced-motion: reduce) {
  .reveal { transition: none; }
  .glow { transition: none; }
}

【JavaScript】
// フラッシュライト追従:仮想カーソルで自動巡回しつつ、操作時は本物にlerp追従
(() => {
  const root = document.querySelector('[data-flashlight-root]');
  const glow = document.querySelector('[data-glow]');
  if (!root) return; // null安全

  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 目標座標と現在座標(lerpで滑らかに追従)
  let tx = 0, ty = 0;     // 目標
  let cx = 0, cy = 0;     // 現在
  let usePointer = false; // 本物のポインタ追従中か
  let lastMove = 0;       // 最後にポインタが動いた時刻
  let raf = 0;
  const IDLE = 1600;      // 無操作でこの時間が過ぎたら自動巡回へ戻る(ms)

  // CSS変数と光の輪へ反映
  const apply = () => {
    root.style.setProperty('--mx', `${cx}px`);
    root.style.setProperty('--my', `${cy}px`);
    if (glow) glow.style.transform = `translate3d(${cx}px, ${cy}px, 0)`;
  };

  // reduced-motion:中央に静的配置して「光で照らすデモ」だと分かる初期表示
  if (reduce) {
    const r = root.getBoundingClientRect();
    cx = tx = r.width / 2;
    cy = ty = r.height / 2;
    root.dataset.active = 'true'; // 光の輪を表示
    apply();
    // pointermoveがあれば即時追従(控えめ動作)
    root.addEventListener('pointermove', (e) => {
      const rr = root.getBoundingClientRect();
      cx = tx = Math.min(Math.max(e.clientX - rr.left, 0), rr.width);
      cy = ty = Math.min(Math.max(e.clientY - rr.top, 0), rr.height);
      apply();
    });
    return;
  }

  // 仮想カーソルの自動経路(リサージュ):ゆっくり画面を照らして巡回
  const autoTarget = (t) => {
    const r = root.getBoundingClientRect();
    const mx = r.width / 2, my = r.height / 2;
    const ax = r.width * 0.32, ay = r.height * 0.28;
    tx = mx + Math.sin(t * 0.00050) * ax;
    ty = my + Math.sin(t * 0.00080 + 0.8) * ay;
  };

  // pointermoveで目標座標を更新(本物に追従)
  root.addEventListener('pointermove', (e) => {
    const r = root.getBoundingClientRect();
    tx = Math.min(Math.max(e.clientX - r.left, 0), r.width);
    ty = Math.min(Math.max(e.clientY - r.top, 0), r.height);
    usePointer = true;
    lastMove = performance.now();
    root.dataset.active = 'true';
  });

  // ホバー時は光をやや広げる
  root.addEventListener('pointerenter', () => { root.style.setProperty('--r', '135px'); });

  // 領域外では光半径を戻す(自動巡回は継続)
  root.addEventListener('pointerleave', () => {
    root.style.setProperty('--r', '120px');
  });

  // メインループ:アイドル中は自動目標、操作後しばらくは本物。lerpで滑らかに寄せる
  const loop = (now) => {
    if (usePointer && now - lastMove > IDLE) usePointer = false; // 無操作で自動へ復帰
    if (!usePointer) {
      autoTarget(now);
      root.dataset.active = 'true'; // 自動巡回中も光の輪を表示
    }
    cx += (tx - cx) * 0.18;
    cy += (ty - cy) * 0.18;
    apply();
    raf = requestAnimationFrame(loop);
  };

  // 初期位置を中央にしてループ開始
  const r0 = root.getBoundingClientRect();
  cx = tx = r0.width / 2;
  cy = ty = r0.height / 2;
  root.dataset.active = 'true';
  apply();
  raf = requestAnimationFrame(loop);
})();

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

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