スポットライトカーソル
CSSの radial-gradient マスクをカーソルで動かし、暗い画面の周辺だけを照らして隠れた層を見せる演出。謎解きや段階的開示の表現に使えます。
ライブデモ
使用例(お題: 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。