デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
暗いヒーローの上に、同じ配置で明るい画像と隠れたメッセージを描いた層を重ね、カーソルの周りの丸い範囲だけを見せます(懐中電灯)。見せる範囲は mask-image の radial-gradient で決め、光の中心と半径を CSS 変数にして、スクリプトからは変数だけを書き換えます。見出しは、下のページでは輪郭だけ、明るい層では墨の塗りにしてあるので、照らした所だけ文字が浮かび上がります。明るい層の文字は下のページを写して作るので、位置が 1px もずれません。見出しの行は、行末の「、」や「。」を数えず、その手前までの字面の左右の端の中央でそろえ(約物は右へぶら下げます)、写しにも同じずれを当てます。約物まで含めて中央にそろえると、約物の空きの分だけ字が左へ寄って見えます。
光の中心は quickTo で 0.35 秒遅れて追い、ヒーローに入ると半径が 0 から 194px へ 0.5 秒の power3.out で広がり、出ると 0.45 秒で閉じます。縁は、半径の 0.3 倍まではそのまま見せ、そこから半径の 1.34 倍まで数段で薄くして、広く柔らかくぼかしています。filter の blur でぼかすより軽く、光の大きさを変えても同じ柔らかさになります。中心には小さな白い丸を遅れずに付け、本物のカーソルはヒーローの中だけ隠します。
写した層の中のリンクは、そのままだと読み上げでもタブ移動でも 2 度数えられます。明るい層には inert と aria-hidden を付け、写した要素の id は外します。また、明るい層は下のページより手前にあるので、キーボードで移ったリンクの焦点の枠が層に隠れます。写しの同じリンクにも枠を描いておくと、照らされていても焦点の位置が見えます。
指で触る端末では明るい層を出さず、見出しを塗って、暗いページだけで読めるようにします。キーボードで中のリンクへ移ったときは、そのリンクを照らします。動きを減らす設定では、遅れと広がる動きを無くして、その場に光を出します。
つまみは、光の半径、縁を薄くする段、明るい層の地のグラデーションと飾りです。光を当てたくなる見出しの後ろに、明るい層でいちばん明るい所(太陽)が来るよう地の向きを決めると、照らしたときの驚きが大きくなります。同じ中身に光の色を足すだけの照らし方と違い、照らすと中身そのものが別の画像に替わるので、キャンペーンやイベントのヒーロー、隠し要素のある 404 ページ、製品の中を透かして見せる紹介に向いています。
コード
<section class="spotlight" aria-labelledby="spotlight-title">
<!-- 暗いページ。光が無くても、これだけで読めるようにしておく -->
<div class="spotlight__page">
<p class="spotlight__eyebrow">Vol. 03 — The hidden issue</p>
<h1 class="spotlight__title" id="spotlight-title"><span class="spotlight__line">目に見えるものが、</span><span class="spotlight__line">すべてじゃない。</span></h1>
<p class="spotlight__lead">カーソルを動かしてみてください。</p>
<a class="spotlight__link" href="/subscribe/">購読する</a>
</div>
<!-- 照らした所だけ見える明るい層。JS が上のページを写して、飾りとメッセージの間に重ねる。読み上げとタブ移動からは外す -->
<div class="spotlight__lit" aria-hidden="true" inert>
<span class="spotlight__sun"></span>
<span class="spotlight__planet"></span>
<span class="spotlight__stripes"></span>
<span class="spotlight__star"></span>
<p class="spotlight__note" style="--left: 15%; --top: 25%; --tilt: -7deg">見つけましたね。</p>
<p class="spotlight__note spotlight__note--dark" style="--left: 75%; --top: 75%; --tilt: 5deg">じつは、仲間を募集中です。</p>
<p class="spotlight__note" style="--left: 44%; --top: 15%; --tilt: 3deg">のぞいてくれて、ありがとう。</p>
</div>
<!-- 光の中心の目印(本物のカーソルの代わり) -->
<span class="spotlight__dot" aria-hidden="true"></span>
</section>
.spotlight {
--bg: #111111;
--ink: #eeece6;
--mute-small: #a8a6a0; /* 16px 未満の文字 */
--line: rgb(238 236 230 / 0.14);
--dark: #111111; /* 明るい層の文字と飾り */
--lit-bg: linear-gradient(135deg, #9a8ebd 0%, #aea0cf 24%, #d99590 52%, #d9ba92 78%, #eae1b3 100%);
--radius: 194px; /* 光の半径(JS が読むので px で書く) */
/* 暗いページの文字。明るい層では下の .spotlight__lit で差し替える */
--fg: var(--ink);
--label: var(--mute-small);
--title-fill: var(--bg);
--title-line: rgb(238 236 230 / 0.42);
position: relative;
overflow: hidden;
background: radial-gradient(circle, var(--line) 0 1px, transparent 1.6px) 0 0 / 32px 32px, var(--bg);
}
/* 光を出さないとき(指で触る端末・スクリプトが動かないとき)は、見出しを塗って読みやすくする */
.spotlight:not(.has-spotlight) { --title-fill: var(--ink); --title-line: transparent; }
.spotlight__page {
position: relative; /* 明るい層の飾り(position を持つ)より手前に重ねるため */
display: grid;
place-content: center;
justify-items: center;
min-height: 100svh;
box-sizing: border-box;
padding: 96px clamp(16px, 7.5vw, 96px);
color: var(--fg);
text-align: center;
}
.spotlight__eyebrow { margin: 0; color: var(--label); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
/* 暗いページでは輪郭だけ。地の色で塗り、線を塗りの下に回して、字の継ぎ目の線を隠す */
.spotlight__title {
margin: 24px 0 0;
color: var(--title-fill);
font-size: clamp(34px, 9vw, 112px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
-webkit-text-stroke: 2px var(--title-line);
paint-order: stroke fill;
}
.spotlight__line { display: block; } /* 行ごとの箱。JS が行ごとに字面の中央をそろえる */
.spotlight__lead { margin: 32px 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
.spotlight__link { padding: 0 24px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--fg); color: var(--fg); font-size: 14px; font-weight: 600; line-height: 40px; letter-spacing: 0.04em; text-decoration: none; }
.spotlight__link:focus-visible,
.spotlight__link.is-focus { outline: 2px solid var(--fg); outline-offset: 4px; }
/* 明るい層: 光の丸(中心 --x --y・半径 --r)の中だけ見せる。縁は半径の 0.3 倍から 1.34 倍まで数段で薄くし、広くぼかす */
.spotlight__lit {
--fg: var(--dark);
--label: rgb(17 17 17 / 0.72);
--title-fill: var(--dark);
--title-line: transparent;
position: absolute;
inset: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
background: var(--lit-bg);
-webkit-mask-image: radial-gradient(circle calc(var(--r, 0px) * 1.34) at var(--x, 0px) var(--y, 0px), #000 22.4%, rgb(0 0 0 / 0.8) 40.3%, rgb(0 0 0 / 0.45) 64.2%, rgb(0 0 0 / 0.14) 85.1%, transparent 100%);
mask-image: radial-gradient(circle calc(var(--r, 0px) * 1.34) at var(--x, 0px) var(--y, 0px), #000 22.4%, rgb(0 0 0 / 0.8) 40.3%, rgb(0 0 0 / 0.45) 64.2%, rgb(0 0 0 / 0.14) 85.1%, transparent 100%);
}
.spotlight__lit > span { position: absolute; translate: -50% -50%; aspect-ratio: 1; border-radius: 50%; }
.spotlight__sun { left: 70%; top: 58%; height: 54%; background: #f6efe0; box-shadow: 0 0 0 22px rgb(246 239 224 / 0.42), 0 0 0 44px rgb(246 239 224 / 0.18); }
.spotlight__planet { left: 10%; top: 98%; height: 64%; background: radial-gradient(circle at 35% 30%, #857ba5 0%, #59517b 58%, #484067 100%); }
.spotlight .spotlight__stripes { left: 86%; top: 83%; width: 28%; height: 6%; aspect-ratio: auto; border-radius: 0; background: repeating-linear-gradient(135deg, rgb(17 17 17 / 0.85) 0 9px, transparent 9px 22px); }
.spotlight .spotlight__star { left: 8%; top: 42%; height: 5%; border-radius: 0; background: var(--dark); clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0 50%, 40% 40%); }
/* 隠れたメッセージ。位置は中心で決める(文言の長さが変わっても、光を当てる所からずれない) */
.spotlight__note {
position: absolute;
left: var(--left);
top: var(--top);
margin: 0;
padding: 0 20px;
border-radius: 999px;
background: #ffffff;
box-shadow: 0 4px 0 var(--dark);
color: var(--dark);
font-size: 18px;
font-weight: 700;
line-height: 44px;
letter-spacing: 0.04em;
white-space: nowrap;
translate: -50% -50%;
rotate: var(--tilt);
}
.spotlight__note--dark { background: var(--dark); color: #d2b999; }
/* 光の中心の目印。JS がつないだときだけ出し、本物のカーソルを隠す */
.spotlight__dot { position: absolute; left: -8px; top: -8px; width: 16px; height: 16px; box-sizing: border-box; border: 2px solid var(--dark); border-radius: 50%; background: #ffffff; opacity: 0; pointer-events: none; }
.has-spotlight,
.has-spotlight a { cursor: none; }
import { gsap } from "gsap";
// 指で触る端末にはポインタが無いので、明るい層は出さず、暗いページだけで見せる
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 見出しの行ごとに、行末の約物(「、」「。」)は数えず、その手前までの字面(インクの左右の端)の中央を行の中央にそろえる。
// 約物は右へぶら下がる(含めて中央にすると、字が左へ寄って見える)。明るい層の写しも同じだけずらす(em で持つ)
document.fonts.ready.then(() => {
document.querySelectorAll(".spotlight__title").forEach((title) => {
const style = getComputedStyle(title), ruler = document.createElement("canvas").getContext("2d");
Object.assign(ruler, { font: `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`, letterSpacing: style.letterSpacing, textAlign: "left", lang: document.documentElement.lang || "ja" });
title.querySelectorAll(".spotlight__line").forEach((line) => {
const { width } = ruler.measureText(line.textContent), ink = ruler.measureText(line.textContent.replace(/[、。,.」』)]+$/u, ""));
line.style.translate = `${(width / 2 - (ink.actualBoundingBoxRight - ink.actualBoundingBoxLeft) / 2) / parseFloat(style.fontSize)}em`;
});
});
});
document.querySelectorAll(".spotlight").forEach((hero) => {
const page = hero.querySelector(".spotlight__page");
const lit = hero.querySelector(".spotlight__lit");
const dot = hero.querySelector(".spotlight__dot");
if (!canHover || !lit) return;
// 暗いページを写して明るい層に重ねる。同じ組みのまま色だけ替えるので、文字の位置が 1px もずれない
const copy = page.cloneNode(true);
copy.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id"));
lit.querySelector(".spotlight__note").before(copy);
hero.classList.add("has-spotlight");
const radius = parseFloat(getComputedStyle(hero).getPropertyValue("--radius"));
const light = { x: 0, y: 0, r: 0 }; // 光の中心と半径(px)
const render = () => {
lit.style.setProperty("--x", `${light.x}px`);
lit.style.setProperty("--y", `${light.y}px`);
lit.style.setProperty("--r", `${light.r}px`);
lit.style.visibility = light.r < 0.5 ? "hidden" : "visible";
};
// 光は少し遅れて追う。動きを減らす設定では遅れずに置く(quickTo は duration が 0 だと値が動かない)
const chase = (prop) =>
reduceMotion
? (value) => {
light[prop] = value;
render();
}
: gsap.quickTo(light, prop, { duration: 0.35, ease: "power3", onUpdate: render });
const moveX = chase("x");
const moveY = chase("y");
// jump なら追わずにその場から始める(閉じた光を、入った位置で開く)
const aim = (x, y, jump) => {
moveX(x, jump ? x : undefined);
moveY(y, jump ? y : undefined);
};
const open = (on) => {
const duration = reduceMotion ? 0 : on ? 0.5 : 0.45;
gsap.to(light, { r: on ? radius : 0, duration, ease: on ? "power3.out" : "power2.inOut", overwrite: "auto", onUpdate: render });
};
const local = (event) => {
const box = hero.getBoundingClientRect();
return [event.clientX - box.left, event.clientY - box.top];
};
hero.addEventListener("pointerenter", (event) => {
if (event.pointerType === "touch") return;
const [x, y] = local(event);
aim(x, y, light.r < 1);
open(true);
dot.style.opacity = "1";
});
hero.addEventListener("pointermove", (event) => {
if (event.pointerType === "touch") return;
const [x, y] = local(event);
aim(x, y);
dot.style.transform = `translate(${x}px, ${y}px)`; // 目印は遅れずに付く
});
hero.addEventListener("pointerleave", () => {
open(false);
dot.style.opacity = "0";
});
// キーボードで中のリンクへ移ったときは、そのリンクを照らす。明るい層の写しにも焦点の枠を描く(下の枠は層に隠れる)
const focusables = (root) => [...root.querySelectorAll("a, button")];
hero.addEventListener("focusin", (event) => {
if (!event.target.matches(":focus-visible")) return;
focusables(copy)[focusables(page).indexOf(event.target)]?.classList.add("is-focus");
const box = hero.getBoundingClientRect();
const r = event.target.getBoundingClientRect();
aim(r.left + r.width / 2 - box.left, r.top + r.height / 2 - box.top, light.r < 1);
open(true);
});
hero.addEventListener("focusout", () => {
copy.querySelector(".is-focus")?.classList.remove("is-focus");
if (!hero.matches(":hover")) open(false);
});
});