スポットライトカーソル

CSSの radial-gradient マスクをカーソルで動かし、暗い画面の周辺だけを照らして隠れた層を見せる演出。謎解きや段階的開示の表現に使えます。

#css#mask#custom-properties#js

ライブデモ

使用例(お題: SaaS FlowDesk)

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

HTML
<!-- FlowDesk: SaaSダッシュボード1画面。暗幕をスポットライトで照らして指標を見せる -->
<div class="fd" data-spotlight-root>
  <header class="fd__bar">
    <span class="fd__logo"><span class="fd__mark"></span>FlowDesk</span>
    <span class="fd__sub">ANALYTICS</span>
  </header>

  <section class="fd__board">
    <div class="fd__kpis">
      <div class="fd__kpi"><span class="fd__num">12,480</span><span class="fd__lbl">アクティブ</span></div>
      <div class="fd__kpi"><span class="fd__num">+18.4%</span><span class="fd__lbl">前週比</span></div>
      <div class="fd__kpi"><span class="fd__num">96.2%</span><span class="fd__lbl">稼働率</span></div>
    </div>
    <div class="fd__chart">
      <span class="fd__bar" style="height:38%"></span>
      <span class="fd__bar" style="height:55%"></span>
      <span class="fd__bar" style="height:46%"></span>
      <span class="fd__bar" style="height:72%"></span>
      <span class="fd__bar" style="height:60%"></span>
      <span class="fd__bar" style="height:88%"></span>
      <span class="fd__bar" style="height:70%"></span>
      <span class="fd__bar" style="height:95%"></span>
    </div>
  </section>

  <p class="fd__hint">スポットライトを当てた箇所だけが明るく見えます</p>
</div>
CSS
/* FlowDesk SaaSダッシュボード: 紺地をスポットライトで照らす */
* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  color: #fff;
  overflow: hidden;
}

.fd {
  --mx: 50%;
  --my: 50%;
  position: relative;
  height: 400px;
  background: #0a1326;
  overflow: hidden;
  cursor: none;
}

/* 暗幕: スポットライト以外を暗く沈める */
.fd::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
  background: radial-gradient(
    circle 150px at var(--mx) var(--my),
    rgba(10,19,38,0) 0%,
    rgba(10,19,38,.4) 42%,
    rgba(10,19,38,.94) 78%
  );
}

/* ヘッダー */
.fd__bar {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: baseline;
  gap: 14px;
  padding: 15px 28px;
}
.fd__logo {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  font-weight: 800;
  font-size: 16px;
}
.fd__mark {
  width: 18px;
  height: 18px;
  border-radius: 6px;
  background: linear-gradient(135deg, #4f7cff, #8fb0ff);
}
.fd__sub {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .24em;
  color: #6f86b8;
}

/* ボード */
.fd__board {
  position: relative;
  z-index: 1;
  padding: 8px 28px 0;
}
.fd__kpis {
  display: flex;
  gap: 14px;
  margin-bottom: 16px;
}
.fd__kpi {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 14px 16px;
  background: rgba(79,124,255,.1);
  border: 1px solid rgba(79,124,255,.3);
  border-radius: 12px;
}
.fd__num {
  font-size: 22px;
  font-weight: 800;
  color: #cfdcff;
}
.fd__lbl {
  font-size: 11px;
  color: #8fa3cf;
}

/* 棒グラフ */
.fd__chart {
  display: flex;
  align-items: flex-end;
  gap: 10px;
  height: 130px;
  padding: 12px 16px;
  background: rgba(255,255,255,.04);
  border: 1px solid rgba(255,255,255,.1);
  border-radius: 12px;
}
.fd__bar {
  flex: 1;
  border-radius: 6px 6px 2px 2px;
  background: linear-gradient(180deg, #6f96ff, #4f7cff);
}

.fd__hint {
  position: relative;
  z-index: 6;
  margin: 12px 0 0;
  text-align: center;
  font-size: 12px;
  letter-spacing: .04em;
  color: rgba(255,255,255,.5);
}
JavaScript
// FlowDesk: スポットライトで指標を照らす。待機中は自動巡回、操作で本物に追従
(() => {
  const root = document.querySelector('[data-spotlight-root]');
  if (!root) return; // null安全

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

  let usePointer = false;
  let lastMove = 0;
  const IDLE = 1600; // 無操作でこの時間が過ぎたら自動巡回へ戻る(ms)

  // CSS変数へマスク中心を反映
  const apply = (x, y) => {
    root.style.setProperty('--mx', `${x}px`);
    root.style.setProperty('--my', `${y}px`);
  };

  // reduced-motion: 中央に静的配置してデモ内容を提示
  if (reduce) {
    const r = root.getBoundingClientRect();
    apply(r.width / 2, r.height / 2);
    return;
  }

  // 仮想カーソルの自動経路: 8の字風にボードを巡回
  const autoPos = (t) => {
    const r = root.getBoundingClientRect();
    const cx = r.width / 2, cy = r.height / 2;
    const ax = r.width * 0.36, ay = r.height * 0.28;
    return {
      x: cx + Math.sin(t * 0.00052) * ax,
      y: cy + Math.sin(t * 0.00083 + 1.2) * ay,
    };
  };

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

  const loop = (now) => {
    if (usePointer && now - lastMove > IDLE) usePointer = false;
    if (!usePointer) {
      const p = autoPos(now);
      apply(p.x, p.y);
    }
    requestAnimationFrame(loop);
  };
  requestAnimationFrame(loop);
})();

コード

HTML
<!-- スポットライトカーソル:CSS変数でマスクを動かし、カーソル周辺だけ照らす -->
<div class="stage" data-spotlight-root>
  <div class="layer dark">
    <h1 class="title">SPOTLIGHT</h1>
    <p class="lead">暗闇の中、カーソルの周りだけが浮かび上がる。</p>
  </div>
  <div class="layer light" data-spotlight>
    <h1 class="title">こんにちは</h1>
    <p class="lead">光が当たると、隠れたメッセージが見えます。</p>
  </div>
</div>
CSS
* { box-sizing: border-box; }
/* iframe全面を暗く塗る(一覧でも白抜けしない) */
html, body {
  margin: 0;
  width: 100%;
  min-height: 100%;
  background: #0a0b14;
}

.stage {
  position: relative;
  width: 100%;
  min-height: 360px;
  height: 100vh;
  max-height: 100%;
  overflow: hidden;
  /* マスク中心の座標をCSS変数で保持(JSが更新) */
  --mx: 50%;
  --my: 50%;
  /* 自前の暗い下地(白下地を必ず覆う) */
  background: #0a0b14;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  cursor: none;
}

/* 各レイヤーは同じ位置に重ねる */
.layer {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  text-align: center;
  padding: 24px;
}
.layer .title { margin: 0 0 10px; font-size: clamp(34px, 7vw, 56px); font-weight: 900; letter-spacing: .06em; }
.layer .lead { margin: 0; font-size: 14px; }

/* 下層:暗い背景+うっすらした案内 */
.dark {
  background: radial-gradient(900px 500px at 50% 50%, #16182a, #0a0b14);
  color: rgba(180,190,220,.18);
}

/* 上層:明るい色。マスクでカーソル周辺だけ表示 */
.light {
  background:
    radial-gradient(700px 400px at 50% 50%, #ffe28a 0%, #ff8fb1 45%, #8a7bff 100%);
  color: #1a1030;
  /* 円形マスク:中心は不透明、外側は透明 */
  -webkit-mask: radial-gradient(circle 110px at var(--mx) var(--my), #000 0%, #000 60%, transparent 75%);
  mask: radial-gradient(circle 110px at var(--mx) var(--my), #000 0%, #000 60%, transparent 75%);
}

/* モーション控えめ:自動巡回せず中央に静的表示(JS側で制御) */
JavaScript
// スポットライトカーソル:仮想カーソルを自動巡回させ、ホバー時は本物に追従
(() => {
  const root = document.querySelector('[data-spotlight-root]');
  if (!root) return; // null安全

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

  let raf = 0;            // アニメーションフレームID
  let usePointer = false; // 本物のポインタ追従中か
  let lastMove = 0;       // 最後にポインタが動いた時刻
  const IDLE = 1600;      // この時間動かなければ自動巡回へ戻る(ms)

  // CSS変数へマスク中心(px)を反映
  const apply = (x, y) => {
    root.style.setProperty('--mx', `${x}px`);
    root.style.setProperty('--my', `${y}px`);
  };

  // reduced-motion:中央付近に静的配置して「何が見えるデモか」を提示
  if (reduce) {
    const r = root.getBoundingClientRect();
    apply(r.width / 2, r.height / 2);
    return;
  }

  // 仮想カーソルの自動経路(リサージュ):ゆっくり画面を巡回
  const autoPos = (t) => {
    const r = root.getBoundingClientRect();
    const cx = r.width / 2, cy = r.height / 2;
    // 画面内に収まる振幅で8の字風に動かす
    const ax = r.width * 0.34, ay = r.height * 0.30;
    const x = cx + Math.sin(t * 0.00052) * ax;
    const y = cy + Math.sin(t * 0.00083 + 1.2) * ay;
    return { x, y };
  };

  // pointermoveで本物の座標を採用
  root.addEventListener('pointermove', (e) => {
    const r = root.getBoundingClientRect();
    const x = Math.min(Math.max(e.clientX - r.left, 0), r.width);
    const y = Math.min(Math.max(e.clientY - r.top, 0), r.height);
    usePointer = true;
    lastMove = performance.now();
    apply(x, y);
  });

  // メインループ:アイドル中は自動巡回、操作後しばらくは本物に追従
  const loop = (now) => {
    if (usePointer && now - lastMove > IDLE) usePointer = false; // 無操作で自動へ復帰
    if (!usePointer) {
      const p = autoPos(now);
      apply(p.x, p.y);
    }
    raf = requestAnimationFrame(loop);
  };
  raf = requestAnimationFrame(loop);
})();

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

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

# 追加してほしい効果
スポットライトカーソル(カスタムカーソル)
CSSの radial-gradient マスクをカーソルで動かし、暗い画面の周辺だけを照らして隠れた層を見せる演出。謎解きや段階的開示の表現に使えます。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- スポットライトカーソル:CSS変数でマスクを動かし、カーソル周辺だけ照らす -->
<div class="stage" data-spotlight-root>
  <div class="layer dark">
    <h1 class="title">SPOTLIGHT</h1>
    <p class="lead">暗闇の中、カーソルの周りだけが浮かび上がる。</p>
  </div>
  <div class="layer light" data-spotlight>
    <h1 class="title">こんにちは</h1>
    <p class="lead">光が当たると、隠れたメッセージが見えます。</p>
  </div>
</div>

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

.stage {
  position: relative;
  width: 100%;
  min-height: 360px;
  height: 100vh;
  max-height: 100%;
  overflow: hidden;
  /* マスク中心の座標をCSS変数で保持(JSが更新) */
  --mx: 50%;
  --my: 50%;
  /* 自前の暗い下地(白下地を必ず覆う) */
  background: #0a0b14;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  cursor: none;
}

/* 各レイヤーは同じ位置に重ねる */
.layer {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  text-align: center;
  padding: 24px;
}
.layer .title { margin: 0 0 10px; font-size: clamp(34px, 7vw, 56px); font-weight: 900; letter-spacing: .06em; }
.layer .lead { margin: 0; font-size: 14px; }

/* 下層:暗い背景+うっすらした案内 */
.dark {
  background: radial-gradient(900px 500px at 50% 50%, #16182a, #0a0b14);
  color: rgba(180,190,220,.18);
}

/* 上層:明るい色。マスクでカーソル周辺だけ表示 */
.light {
  background:
    radial-gradient(700px 400px at 50% 50%, #ffe28a 0%, #ff8fb1 45%, #8a7bff 100%);
  color: #1a1030;
  /* 円形マスク:中心は不透明、外側は透明 */
  -webkit-mask: radial-gradient(circle 110px at var(--mx) var(--my), #000 0%, #000 60%, transparent 75%);
  mask: radial-gradient(circle 110px at var(--mx) var(--my), #000 0%, #000 60%, transparent 75%);
}

/* モーション控えめ:自動巡回せず中央に静的表示(JS側で制御) */

【JavaScript】
// スポットライトカーソル:仮想カーソルを自動巡回させ、ホバー時は本物に追従
(() => {
  const root = document.querySelector('[data-spotlight-root]');
  if (!root) return; // null安全

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

  let raf = 0;            // アニメーションフレームID
  let usePointer = false; // 本物のポインタ追従中か
  let lastMove = 0;       // 最後にポインタが動いた時刻
  const IDLE = 1600;      // この時間動かなければ自動巡回へ戻る(ms)

  // CSS変数へマスク中心(px)を反映
  const apply = (x, y) => {
    root.style.setProperty('--mx', `${x}px`);
    root.style.setProperty('--my', `${y}px`);
  };

  // reduced-motion:中央付近に静的配置して「何が見えるデモか」を提示
  if (reduce) {
    const r = root.getBoundingClientRect();
    apply(r.width / 2, r.height / 2);
    return;
  }

  // 仮想カーソルの自動経路(リサージュ):ゆっくり画面を巡回
  const autoPos = (t) => {
    const r = root.getBoundingClientRect();
    const cx = r.width / 2, cy = r.height / 2;
    // 画面内に収まる振幅で8の字風に動かす
    const ax = r.width * 0.34, ay = r.height * 0.30;
    const x = cx + Math.sin(t * 0.00052) * ax;
    const y = cy + Math.sin(t * 0.00083 + 1.2) * ay;
    return { x, y };
  };

  // pointermoveで本物の座標を採用
  root.addEventListener('pointermove', (e) => {
    const r = root.getBoundingClientRect();
    const x = Math.min(Math.max(e.clientX - r.left, 0), r.width);
    const y = Math.min(Math.max(e.clientY - r.top, 0), r.height);
    usePointer = true;
    lastMove = performance.now();
    apply(x, y);
  });

  // メインループ:アイドル中は自動巡回、操作後しばらくは本物に追従
  const loop = (now) => {
    if (usePointer && now - lastMove > IDLE) usePointer = false; // 無操作で自動へ復帰
    if (!usePointer) {
      const p = autoPos(now);
      apply(p.x, p.y);
    }
    raf = requestAnimationFrame(loop);
  };
  raf = requestAnimationFrame(loop);
})();

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

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