デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ヒーローの背景に、細かいドットの格子を CSS の radial-gradient の模様で敷き、その上に全面の canvas を重ねます。ポインタが入ると、光の中心から半径の中にあるドットだけを canvas に描き直し、近いものほど大きく濃い明るいドットで、地のドットの上から覆います。近さの効き方は余弦の山で、真下でいちばん大きく明るく、半径の縁でなめらかに地のドットへ戻ります。光の中心には、ごく淡いにじみも重ねています。
ドットの間隔は 24px、地のドットの半径は 1.3px、いちばん近いドットは 5px まで大きくなり、光が届く半径は 230px です。光の中心は quickTo で 0.35 秒かけてポインタに追いつくので、明るいまとまりが少し遅れてついてきます。ヒーローに入ると 0.5 秒で明るさが立ち上がり、出ると 0.6 秒でしぼみます。
描き直すのは、半径を囲む四角の中のドットだけにします。画面いっぱいのドットを毎コマ描くと、大きな画面では数千個になって重くなります。描くのは光が動いているあいだだけで、ポインタが止まって追いつけば描き直しも止まります。canvas の大きさは devicePixelRatio を掛けた画素数にし、描く座標は CSS の px のままにします。掛けないと高精細の画面でドットがにじみ、地の模様より甘く見えます。
canvas と地の模様にはポインタを通し、上のリンクやボタンをそのまま押せるようにします。指で触る端末ではつながず、地のドットの格子だけを見せます。キーボードでボタンへ移ったときは、そのボタンの中央を光らせます。動きを減らす設定では、遅れと立ち上がりを無くし、その場で光らせます。
つまみは、ドットの間隔と大きさ、光が届く半径、追いつく時間です。半径を広げるとスポットライトのような大きな光になり、狭めるとカーソルのすぐ下だけが灯る控えめな表情になります。中心から波が広がる格子と違い、ポインタとの距離だけで決まるので、ヒーローやセクションの背景、サービス紹介や採用ページの見出しの後ろに、軽く敷ける触り心地として向いています。
コード
<section class="dot-hero" aria-labelledby="dot-hero-title">
<!-- カーソルの近くのドットを明るく描く層。飾りなので読み上げから外す -->
<canvas class="dot-hero__glow" aria-hidden="true"></canvas>
<div class="dot-hero__body">
<p class="dot-hero__eyebrow">Design studio</p>
<h1 class="dot-hero__title" id="dot-hero-title">細部まで、<br>ていねいに。</h1>
<p class="dot-hero__lead">ここに説明文が入ります。</p>
<div class="dot-hero__actions">
<a class="dot-hero__button dot-hero__button--primary" href="/contact/">相談する</a>
<a class="dot-hero__button" href="/work/">実績を見る</a>
</div>
</div>
</section>
.dot-hero {
--bg: #111111;
--ink: #eeece6;
--mute: #a8a6a0;
--dot: #3a3a38; /* 地のドット */
--dot-light: 147, 178, 201; /* 光ったドットと光のにじみの色(R, G, B)。パレットのスレートの彩度を 1.25 倍にした値 */
--dot-gap: 24px; /* ドットの間隔 */
--dot-size: 1.3px; /* 地のドットの半径 */
--dot-max: 5px; /* いちばん近いドットの半径 */
--glow-radius: 230px; /* 光が届く半径 */
position: relative;
display: grid;
align-content: center;
min-height: 100vh;
box-sizing: border-box;
padding: 96px clamp(24px, 7.5vw, 96px);
overflow: hidden;
/* 地のドットは背景の模様で敷く。JS が動かなくても、指で触る端末でも、この格子は見える */
background:
radial-gradient(circle, var(--dot) var(--dot-size), transparent calc(var(--dot-size) + 0.6px)) 0 0 / var(--dot-gap) var(--dot-gap),
var(--bg);
color: var(--ink);
}
/* 明るいドットだけを描く層。下のリンクを押せるよう、ポインタを通す */
.dot-hero__glow {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.dot-hero__body {
position: relative;
}
.dot-hero__eyebrow {
margin: 0 0 24px;
color: var(--mute);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.dot-hero__title {
margin: 0;
font-size: clamp(44px, 7vw, 72px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
word-break: keep-all; /* 和文の見出しを語の途中で折らない */
}
.dot-hero__lead {
max-width: 21.5em; /* 広い画面では「実装まで」のあとで折り返す */
margin: 24px 0 40px;
font-size: 18px;
line-height: 1.8;
letter-spacing: 0.04em;
word-break: keep-all; /* 折り返すのは HTML の wbr の所だけ(auto-phrase は「手が/けて」と割った) */
}
.dot-hero__actions {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.dot-hero__button {
display: inline-flex;
align-items: center;
height: 48px;
padding: 0 32px;
border: 1px solid var(--ink);
border-radius: 999px;
color: var(--ink);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.dot-hero__button--primary {
background: var(--ink);
color: var(--bg);
}
.dot-hero__button:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
import { gsap } from "gsap";
// 指で触る端末にはポインタが無いので、つながずに地のドットだけにする
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".dot-hero").forEach((hero) => {
const canvas = hero.querySelector(".dot-hero__glow");
if (!canHover || !canvas) return;
const g = canvas.getContext("2d");
const css = getComputedStyle(hero);
const read = (name) => parseFloat(css.getPropertyValue(name));
const [gap, size, max, radius] = ["--dot-gap", "--dot-size", "--dot-max", "--glow-radius"].map(read);
const rgb = css.getPropertyValue("--dot-light").trim();
const glow = { x: 0, y: 0, power: 0 }; // 光の中心と強さ(0〜1)
let [width, height] = [0, 0];
// 半径の中のドットだけを、近いほど大きく濃く描く。外は CSS の地のドットのまま
const draw = () => {
g.clearRect(0, 0, width, height);
if (glow.power < 0.005) return;
const halo = g.createRadialGradient(glow.x, glow.y, 0, glow.x, glow.y, radius * 1.2);
halo.addColorStop(0, `rgba(${rgb}, ${0.08 * glow.power})`);
halo.addColorStop(1, `rgba(${rgb}, 0)`);
g.fillStyle = halo;
g.fillRect(0, 0, width, height);
g.fillStyle = `rgb(${rgb})`;
const [i0, i1] = [Math.max(0, Math.floor((glow.x - radius) / gap)), Math.ceil((glow.x + radius) / gap)];
const [j0, j1] = [Math.max(0, Math.floor((glow.y - radius) / gap)), Math.ceil((glow.y + radius) / gap)];
for (let j = j0; j <= j1; j++) {
for (let i = i0; i <= i1; i++) {
const cx = (i + 0.5) * gap; // 背景の模様と同じ、升目の中心
const cy = (j + 0.5) * gap;
const d = Math.hypot(cx - glow.x, cy - glow.y);
if (d >= radius) continue;
const k = (0.5 + 0.5 * Math.cos((Math.PI * d) / radius)) * glow.power; // 真下で 1、縁で 0
g.globalAlpha = k;
g.beginPath();
g.arc(cx, cy, size + (max - size) * k, 0, Math.PI * 2);
g.fill();
}
}
g.globalAlpha = 1;
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
({ width, height } = hero.getBoundingClientRect());
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
};
// 動きを減らす設定では遅れずに置く(quickTo は duration が 0 だと値が動かない)
const set = (prop) => (value) => {
glow[prop] = value;
draw();
};
const chase = (prop) => (reduceMotion ? set(prop) : gsap.quickTo(glow, prop, { duration: 0.35, ease: "power3", onUpdate: draw }));
const toX = chase("x");
const toY = chase("y");
const light = (on) => gsap.to(glow, { power: on ? 1 : 0, duration: reduceMotion ? 0 : on ? 0.5 : 0.6, ease: "power2.out", overwrite: "auto", onUpdate: draw });
const aim = (x, y) => {
// しぼみきっていれば、光を遠くから飛ばさず、その場で光らせる
if (glow.power < 0.02) {
toX(x, x);
toY(y, y);
}
toX(x);
toY(y);
};
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;
aim(...local(event));
light(true);
});
hero.addEventListener("pointermove", (event) => event.pointerType !== "touch" && aim(...local(event)));
hero.addEventListener("pointerleave", () => light(false));
// キーボードでボタンへ移ったときは、そのボタンの中央を光らせる
hero.addEventListener("focusin", (event) => {
if (!event.target.matches(":focus-visible")) return;
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(true);
});
hero.addEventListener("focusout", () => hero.matches(":hover") || light(false));
window.addEventListener("resize", resize);
resize();
});