フラッシュライト追従
暗いヒーローをカーソル追従の radial-gradient マスクで懐中電灯のように照らし、隠しテキストや画像を浮かび上がらせる演出。離れた周辺は闇に沈みます。謎解きや段階的開示の表現に使えます。
ライブデモ
使用例(お題: アイドルグループ 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。